diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 00000000000..8bf7cc27a18 --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0c4c30240ca..cc1f11cd8ba 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,15 +2,22 @@ "name": "T3 Code Dev", "image": "debian:bookworm", "features": { - "ghcr.io/devcontainers-extra/features/bun:1": {}, + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers-extra/features/bun:1": { + "version": "1.3.11" + }, "ghcr.io/devcontainers/features/node:1": { - "version": "24", - "nodeGypDependencies": true + "version": "24.13.1" }, "ghcr.io/devcontainers/features/python:1": { - "version": "3.12" + "version": "3.10", + "installTools": false } }, + "overrideFeatureInstallOrder": [ + "ghcr.io/devcontainers/features/git", + "ghcr.io/devcontainers-extra/features/bun" + ], "postCreateCommand": { "bun-install": "bun install --backend=copyfile --frozen-lockfile" }, diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 32e35d7cafa..75274095a12 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -93,6 +93,8 @@ Examples: 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. @@ -108,6 +110,67 @@ A single environment may have many endpoints: 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. @@ -151,6 +214,8 @@ Benefits: - 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: @@ -170,6 +235,8 @@ This is especially useful when: - 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. @@ -185,6 +252,8 @@ After that, the renderer should still connect using an ordinary WebSocket URL ag 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: @@ -227,6 +296,15 @@ The recommended T3 flow is: 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. @@ -267,6 +345,8 @@ T3 already supports a WebSocket auth token on the server. That should become a f 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. diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 5535d54a5b4..73376110d9a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -30,3 +30,6 @@ github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 github:shivamhwp +github:jappyjan +github:justsomelegs +github:UtkarshUsername diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c2d2c83813..e3329b1dad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: quality: name: Format, Lint, Typecheck, Test, Browser Test, Build - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -71,12 +71,12 @@ jobs: - name: Verify preload bundle output run: | - test -f apps/desktop/dist-electron/preload.js - grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js + test -f apps/desktop/dist-electron/preload.cjs + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs release_smoke: name: Release Smoke - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -92,5 +92,8 @@ jobs: with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 15c04d663d3..af557dff62d 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -124,6 +124,9 @@ jobs: group: pr-size-${{ github.event.pull_request.number }} cancel-in-progress: true steps: + # This pull_request_target job may fetch untrusted PR commits only as passive + # git data. Do not add dependency installs, build/test scripts, or cache + # actions here; use pull_request plus workflow_run for that pattern instead. - name: Checkout base repository uses: actions/checkout@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac01ee87934..d90479087cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,60 +1,89 @@ -name: Release Desktop +name: Release on: push: tags: - "v*.*.*" + - "!v*-nightly.*" + schedule: + - cron: "0 */3 * * *" workflow_dispatch: inputs: + channel: + description: "Release channel" + required: false + default: stable + type: choice + options: + - stable + - nightly version: description: "Release version (for example 1.2.3 or v1.2.3)" - required: true + required: false type: string permissions: - contents: write - id-token: write + contents: read + id-token: none jobs: + check_changes: + name: Check for changes since last nightly + if: github.event_name == 'schedule' + runs-on: blacksmith-8vcpu-ubuntu-2404 + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - id: check + name: Compare HEAD to last nightly tag + run: | + last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) + if [[ -z "$last_nightly_tag" ]]; then + echo "No previous nightly tag found. Proceeding with release." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") + head_sha=$(git rev-parse HEAD) + + if [[ "$last_nightly_sha" == "$head_sha" ]]; then + echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + preflight: name: Preflight - runs-on: ubuntu-24.04 + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: + release_channel: ${{ steps.release_meta.outputs.release_channel }} version: ${{ steps.release_meta.outputs.version }} tag: ${{ steps.release_meta.outputs.tag }} + release_name: ${{ steps.release_meta.outputs.name }} + short_sha: ${{ steps.release_meta.outputs.short_sha }} + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} ref: ${{ github.sha }} steps: - name: Checkout uses: actions/checkout@v6 - - - id: release_meta - name: Resolve release version - shell: bash - run: | - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${{ github.event.inputs.version }}" - else - raw="${GITHUB_REF_NAME}" - fi - - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - fi + with: + fetch-depth: 0 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -69,6 +98,60 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - id: release_meta + name: Resolve release version + shell: bash + env: + DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} + DISPATCH_VERSION: ${{ github.event.inputs.version }} + NIGHTLY_DATE: ${{ github.run_started_at }} + NIGHTLY_SHA: ${{ github.sha }} + NIGHTLY_RUN_NUMBER: ${{ github.run_number }} + run: | + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + + node scripts/resolve-nightly-release.ts \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$NIGHTLY_SHA" \ + --github-output + + echo "release_channel=nightly" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + raw="${DISPATCH_VERSION}" + if [[ -z "$raw" ]]; then + echo "workflow_dispatch stable releases require the version input." >&2 + exit 1 + fi + else + raw="${GITHUB_REF_NAME}" + fi + + version="${raw#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release version: $raw" >&2 + exit 1 + fi + + echo "release_channel=stable" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + fi + fi + - name: Lint run: bun run lint @@ -78,9 +161,18 @@ jobs: - name: Test run: bun run test + - id: previous_tag + name: Resolve previous release tag + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.release_channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + build: name: Build ${{ matrix.label }} needs: preflight + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 strategy: @@ -88,25 +180,30 @@ jobs: matrix: include: - label: macOS arm64 - runner: macos-14 + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: macos-15-intel + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 - label: Linux x64 - runner: ubuntu-24.04 + runner: blacksmith-32vcpu-ubuntu-2404 platform: linux target: AppImage arch: x64 - label: Windows x64 - runner: windows-2022 + runner: blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 + # - label: Windows arm64 + # runner: windows-11-arm + # platform: win + # target: nsis + # arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -130,6 +227,85 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Install Spectre-mitigated MSVC libs + if: matrix.platform == 'win' + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + + - name: Prepare Azure Trusted Signing + if: matrix.platform == 'win' + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + $ErrorActionPreference = "Stop" + + $requiredSecrets = @( + $env:AZURE_TENANT_ID, + $env:AZURE_CLIENT_ID, + $env:AZURE_CLIENT_SECRET, + $env:AZURE_TRUSTED_SIGNING_ENDPOINT, + $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, + $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, + $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME + ) + if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { + Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." + exit 0 + } + + try { + Install-PackageProvider ` + -Name NuGet ` + -MinimumVersion 2.8.5.201 ` + -Force ` + -Scope CurrentUser ` + -ErrorAction Stop + } catch { + Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" + } + + Install-Module ` + -Name TrustedSigning ` + -MinimumVersion 0.5.0 ` + -Force ` + -AllowClobber ` + -Repository PSGallery ` + -Scope CurrentUser ` + -ErrorAction Stop + + Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force + Get-Command Invoke-TrustedSigning -ErrorAction Stop + + $moduleRoots = @( + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") + ) + $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | + Where-Object { $_ -and (Test-Path $_) } | + Select-Object -Unique + "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + - name: Build desktop artifact shell: bash env: @@ -206,18 +382,31 @@ jobs: "release/*.AppImage" \ "release/*.exe" \ "release/*.blockmap" \ - "release/latest*.yml"; do + "release/*.yml"; do for file in $pattern; do cp "$file" release-publish/ done done if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - if [[ -f release-publish/latest-mac.yml ]]; then - mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml" - fi + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" + done fi + # Enable if Windows arm64 builds are enabled. + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the + # release job can merge matching arm64/x64 manifests back into one + # canonical manifest per channel. + # if [[ "${{ matrix.platform }}" == "win" ]]; then + # shopt -s nullglob + # for manifest in release-publish/*.yml; do + # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" + # done + # fi + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: @@ -228,8 +417,12 @@ jobs: publish_cli: name: Publish CLI to npm needs: [preflight, build] - runs-on: ubuntu-24.04 + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@v6 @@ -248,33 +441,53 @@ jobs: registry-url: https://registry.npmjs.org - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --filter=t3 --filter=@t3tools/web --filter=@t3tools/scripts - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Build web package + run: bun --filter=@t3tools/web run build + - name: Build CLI package - run: bun run build --filter=@t3tools/web --filter=t3 + run: bun --filter=t3 run build - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose + run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose release: name: Publish GitHub Release needs: [preflight, build, publish_cli] - runs-on: ubuntu-24.04 + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - name: Setup Node uses: actions/setup-node@v6 with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile --filter=@t3tools/scripts + - name: Download all desktop artifacts uses: actions/download-artifact@v8 with: @@ -284,17 +497,72 @@ jobs: - name: Merge macOS updater manifests run: | - node scripts/merge-mac-update-manifests.ts \ - release-assets/latest-mac.yml \ - release-assets/latest-mac-x64.yml - rm -f release-assets/latest-mac-x64.yml + shopt -s nullglob + for x64_manifest in release-assets/*-mac-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" + rm -f "$x64_manifest" + fi + done + + # - name: Merge Windows updater manifests + # run: | + # shopt -s nullglob + # found_windows_manifest=false + # for x64_manifest in release-assets/*-win-x64.yml; do + # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + # continue + # fi + + # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" + # output_manifest="${x64_manifest/-win-x64.yml/.yml}" + # if [[ ! -f "$arm64_manifest" ]]; then + # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + # exit 1 + # fi + + # found_windows_manifest=true + # node scripts/merge-update-manifests.ts --platform win \ + # "$arm64_manifest" \ + # "$x64_manifest" \ + # "$output_manifest" + # rm -f "$arm64_manifest" "$x64_manifest" + # done + + # if [[ "$found_windows_manifest" != true ]]; then + # echo "No Windows updater manifests found to merge." >&2 + # exit 1 + # fi - name: Publish release + if: needs.preflight.outputs.previous_tag != '' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.ref }} + name: ${{ needs.preflight.outputs.release_name }} + generate_release_notes: true + previous_tag: ${{ needs.preflight.outputs.previous_tag }} + prerelease: ${{ needs.preflight.outputs.is_prerelease }} + make_latest: ${{ needs.preflight.outputs.make_latest }} + files: | + release-assets/*.dmg + release-assets/*.zip + release-assets/*.AppImage + release-assets/*.exe + release-assets/*.blockmap + release-assets/*.yml + fail_on_unmatched_files: true + token: ${{ steps.app_token.outputs.token }} + + - name: Publish first release + if: needs.preflight.outputs.previous_tag == '' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} - name: T3 Code v${{ needs.preflight.outputs.version }} + name: ${{ needs.preflight.outputs.release_name }} generate_release_notes: true prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} @@ -304,13 +572,107 @@ jobs: release-assets/*.AppImage release-assets/*.exe release-assets/*.blockmap - release-assets/latest*.yml + release-assets/*.yml fail_on_unmatched_files: true + token: ${{ steps.app_token.outputs.token }} + + deploy_web: + name: Deploy hosted web app + needs: [preflight, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} + T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} + T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} + VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install release tooling dependencies + run: bun install --frozen-lockfile --filter=@t3tools/scripts --filter=@t3tools/web + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Refresh release lockfile + run: bun install --lockfile-only --ignore-scripts + + - name: Deploy and alias channel + shell: bash + run: | + set -euo pipefail + + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then + echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 + exit 1 + fi + + router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" + latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}" + nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}" + router_domain="${router_url#http://}" + router_domain="${router_domain#https://}" + router_domain="${router_domain%%/*}" + + if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then + channel_domain="$latest_domain" + channel_name="latest" + else + channel_domain="$nightly_domain" + channel_name="nightly" + fi + + vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" + vercel_scope_args=(--scope "$vercel_scope") + + echo "Deploying hosted web app for $channel_name channel." + deployment_url="$( + bunx vercel@53.1.1 deploy \ + --prod \ + --skip-domain \ + --yes \ + --token "$VERCEL_TOKEN" \ + "${vercel_scope_args[@]}" \ + --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ + --build-env "VITE_HOSTED_APP_URL=$router_url" \ + --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" + )" + + echo "Aliasing $deployment_url to $channel_domain." + bunx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \ + --token "$VERCEL_TOKEN" \ + "${vercel_scope_args[@]}" + + if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then + echo "Aliasing $deployment_url to router domain $router_domain." + bunx vercel@53.1.1 alias set "$deployment_url" "$router_domain" \ + --token "$VERCEL_TOKEN" \ + "${vercel_scope_args[@]}" + fi finalize: name: Finalize release + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - id: app_token @@ -349,6 +711,9 @@ jobs: with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile --filter=@t3tools/scripts + - id: update_versions name: Update version strings env: @@ -357,7 +722,7 @@ jobs: - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' - run: bunx oxfmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json + run: bunx oxfmt@0.40.0 apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json - name: Refresh lockfile if: steps.update_versions.outputs.changed == 'true' @@ -380,3 +745,61 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock git commit -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main + + announce_discord: + name: Announce release on Discord + if: | + always() && !cancelled() && + needs.preflight.result == 'success' && + needs.release.result == 'success' && + needs.deploy_web.result == 'success' && + (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') + needs: [preflight, release, deploy_web, finalize] + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile --filter=@t3tools/scripts + + - name: Announce prerelease on Discord + if: needs.preflight.outputs.is_prerelease == 'true' + continue-on-error: true + env: + DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: | + node scripts/notify-discord-release.ts prerelease \ + --role-id "$DISCORD_MENTION_ROLE_ID" \ + --release-name "${{ needs.preflight.outputs.release_name }}" \ + --release-version "${{ needs.preflight.outputs.version }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" + + - name: Announce latest release on Discord + if: needs.preflight.outputs.make_latest == 'true' + continue-on-error: true + env: + DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: | + node scripts/notify-discord-release.ts latest \ + --role-id "$DISCORD_MENTION_ROLE_ID" \ + --release-name "${{ needs.preflight.outputs.release_name }}" \ + --release-version "${{ needs.preflight.outputs.version }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" diff --git a/.gitignore b/.gitignore index 6c48782f9ac..5e941c7b9f0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ apps/web/src/components/__screenshots__ __screenshots__/ .tanstack squashfs-root/ +.vercel +.gstack/ +dist-electron/ +.electron-runtime/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 776d11b8035..3d65d9c93bb 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,7 +10,16 @@ "*.tsbuildinfo", "**/routeTree.gen.ts", "apps/web/public/mockServiceWorker.js", - "apps/web/src/lib/vendor/qrcodegen.ts" + "apps/web/src/lib/vendor/qrcodegen.ts", + "*.icon/**" ], - "sortPackageJson": {} + "sortPackageJson": {}, + "overrides": [ + { + "files": [".devcontainer/devcontainer.json"], + "options": { + "trailingComma": "none" + } + } + ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 8ea8fbf2b8b..de3a72ae112 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -9,6 +9,7 @@ "**/routeTree.gen.ts" ], "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], + "jsPlugins": ["./oxlint-plugin-t3code/index.ts"], "categories": { "correctness": "warn", "suspicious": "warn", @@ -17,6 +18,8 @@ "rules": { "react-in-jsx-scope": "off", "eslint/no-shadow": "off", - "eslint/no-await-in-loop": "off" + "eslint/no-await-in-loop": "off", + "eslint/no-underscore-dangle": "off", + "t3code/no-inline-schema-compile": "warn" } } diff --git a/.plans/19-remote-endpoints-hosted-static.md b/.plans/19-remote-endpoints-hosted-static.md new file mode 100644 index 00000000000..ada2f681ce4 --- /dev/null +++ b/.plans/19-remote-endpoints-hosted-static.md @@ -0,0 +1,349 @@ +# Remote Endpoints and Hosted Static App Plan + +## Purpose + +Make remote access feel first-class while keeping the free DIY path open. + +The immediate product goal is: + +- users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel +- users can generate a hosted pairing link for `app.t3.codes` +- the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin +- all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX + +This plan intentionally leaves the paid T3 cloud tunnel fabric out of scope. It defines the OSS foundation that T3 Tunnel should later plug into. + +## Current State + +Already present or in progress: + +- Server auth distinguishes bootstrap credentials from session credentials. +- One-time pairing credentials can be exchanged for browser sessions or bearer sessions. +- Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token. +- Remote environment WebSocket connections use a short-lived WebSocket token. +- Pairing URLs can carry tokens in the URL fragment. +- Hosted `/pair?host=...#token=...` can add a saved environment. +- Hosted static startup can avoid assuming the page origin is the backend. + +Main gaps: + +- Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs. +- Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model. +- Tailscale/MagicDNS endpoints are not detected or surfaced. +- Hosted-static empty/offline states are still thin. +- Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure. + +## Core Decision: Add `AdvertisedEndpoint` + +Add a new first-class contract instead of extending the environment descriptor. + +### Why not extend `ExecutionEnvironmentDescriptor` + +`ExecutionEnvironmentDescriptor` answers: "What environment is this?" + +Examples: + +- environment id +- label +- platform +- server version +- capabilities + +`AdvertisedEndpoint` answers: "How can a client reach this environment right now?" + +Examples: + +- loopback URL +- LAN URL +- Tailscale IP URL +- MagicDNS/Serve URL +- manual URL +- future T3 Tunnel URL +- browser compatibility and exposure level + +Those are different lifecycles. One environment can have many endpoints, endpoints can appear/disappear as network interfaces change, and the same descriptor is returned regardless of which endpoint the client used. Extending the descriptor would blur environment identity with transport reachability and make saved environments harder to reason about. + +### Target Contract + +Add a schema in `packages/contracts`, likely `remoteAccess.ts`: + +```ts +type AdvertisedEndpointProvider = + | "loopback" + | "lan" + | "tailscale-ip" + | "tailscale-magicdns" + | "manual" + | "t3-tunnel"; + +type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public"; + +type AdvertisedEndpointCompatibility = { + hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown"; + desktopApp: "compatible" | "unknown"; +}; + +type AdvertisedEndpoint = { + id: string; + provider: AdvertisedEndpointProvider; + label: string; + httpBaseUrl: string; + wsBaseUrl: string; + visibility: AdvertisedEndpointVisibility; + compatibility: AdvertisedEndpointCompatibility; + source: "server" | "desktop" | "user"; + status: "available" | "unavailable" | "unknown"; + isDefault?: boolean; +}; +``` + +Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`. + +## HTTP/WS and HTTPS/WSS Readiness + +The codebase is partially ready, but the UX and compatibility model are not explicit enough. + +What is ready: + +- Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`. +- Saved environments store both HTTP and WebSocket base URLs. +- Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable. +- WebSocket connections can use a dynamically issued `wsToken`. +- Server CORS support exists for browser remote auth endpoints. + +What is not solved by code alone: + +- `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content. +- `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust. +- LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app. +- The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible. + +Policy: + +- Support both HTTP/WS and HTTPS/WSS at the runtime layer. +- Mark endpoint compatibility at the product layer. +- Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback. + +## Architecture + +### Endpoint Sources + +Endpoint records can come from several providers: + +1. **Server runtime** + - headless bind host and port + - server-known explicit advertised host config + +2. **Desktop shell** + - loopback backend URL + - LAN exposure state + - network interface discovery + - Tailscale CLI/status discovery + +3. **User configuration** + - manually added hostnames + - preferred endpoint labels + - hidden/disabled endpoints + +4. **Future cloud provider** + - T3 Tunnel endpoint + - billing/account status + - tunnel lifecycle state + +### Endpoint Registry + +Create a central runtime registry: + +- `packages/contracts/src/remoteAccess.ts` +- `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification +- `apps/server/src/remoteAccess/*` for server/headless endpoints +- `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints +- `apps/web/src/environments/endpoints/*` for client-side display and pairing selection + +The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel. + +### Pairing Link Generation + +Move hosted pairing link generation to endpoint-driven input: + +```ts +buildHostedPairingUrl({ + endpoint: AdvertisedEndpoint, + token, +}); +``` + +Generated URL: + +```text +https://app.t3.codes/pair?host=#token= +``` + +Use fragment tokens by default. Continue accepting `?token=` for compatibility. + +## Phase 1: Endpoint Abstraction + +### Goals + +- Centralize URL normalization, protocol derivation, and compatibility checks. +- Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection. +- Preserve all current remote behavior. + +### Tasks + +1. Add `AdvertisedEndpoint` schemas to `packages/contracts`. +2. Add shared helpers: + - normalize HTTP base URL + - derive WebSocket base URL + - classify loopback/private/LAN/Tailscale/public host + - classify hosted HTTPS compatibility +3. Add server endpoint discovery: + - loopback endpoint + - configured non-loopback endpoint + - explicit advertised host override +4. Add desktop endpoint discovery: + - local loopback + - LAN exposure endpoint + - endpoint status labels +5. Add WebSocket/API method or existing config field for endpoint snapshots. +6. Refactor settings connections UI: + - render endpoint rows + - endpoint picker for pairing link copy + - show compatibility warnings +7. Refactor hosted link builder to accept endpoint records. +8. Add tests for URL normalization and compatibility classification. + +### Acceptance Criteria + +- Existing LAN/network access UI still works. +- Pairing links are generated from endpoint records. +- Loopback endpoints never produce hosted pairing links silently. +- HTTP private-network endpoints are marked incompatible with `app.t3.codes`. +- No remote environment runtime changes are required for existing saved environments. + +## Phase 2: BYO Tailscale/MagicDNS + +### Goals + +- Detect free DIY Tailscale reachability. +- Surface Tailscale endpoints as normal advertised endpoints. +- Keep users in control of their own tailnet. + +### Tasks + +1. Detect Tailscale IPs from network interfaces: + - IPv4 `100.64.0.0/10` + - mark as `provider: "tailscale-ip"` +2. Add optional desktop-side `tailscale status --json` discovery: + - MagicDNS hostname + - Tailscale Serve/Funnel HTTPS endpoint if discoverable + - graceful failure if CLI is missing +3. Add manual Tailscale endpoint override: + - hostname + - label + - preferred/default flag +4. Show Tailscale endpoint rows in settings: + - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked + - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS +5. Generate pairing links using selected Tailscale endpoint. +6. Document DIY setup: + - local desktop-to-desktop over Tailscale + - hosted app requirements + - why HTTPS matters + +### Acceptance Criteria + +- A machine on Tailscale shows a Tailscale endpoint without paid features. +- Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible. +- Users can still copy token-only/manual values when endpoint compatibility is unknown. +- Tailscale is optional and never required for regular LAN/loopback use. + +## Phase 3: Hosted Static App Completion + +### Goals + +- `app.t3.codes` works as a real client shell. +- It can pair, persist, reconnect, and clearly explain offline/incompatible states. + +### Tasks + +1. Finish hosted-static root behavior: + - no primary backend required + - saved environment hydration before initial routing decisions + - first saved environment selected as active +2. Add hosted empty state: + - no saved environments + - paste pairing URL + - add host + token +3. Add offline saved environment UI: + - last connected + - reconnect + - remove + - copy/add alternate endpoint +4. Audit primary-backend assumptions: + - command palette + - settings pages + - server config atom defaults + - keybindings + - provider/model lists + - update/desktop-only affordances +5. Add route tests for: + - hosted `/pair?host=...#token=...` + - hosted root with no saved environments + - hosted root with saved environment + - primary backend unavailable but saved environment present +6. Add deployment hardening: + - SPA fallback + - strict CSP + - no third-party scripts + - no query token logging + - disable or hide source maps in production if needed +7. Add browser error messages: + - mixed content + - unreachable backend + - CORS failure + - certificate failure + +### Acceptance Criteria + +- `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload. +- A saved environment can be used without any backend at `app.t3.codes`. +- Offline machines show a useful state instead of a generic boot error. +- HTTP endpoints are still supported in desktop/native/local contexts. +- Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints. + +## Phase 4: Future T3 Tunnel Provider + +Not part of the current implementation, but the endpoint abstraction should make it straightforward. + +Future tunnel provider responsibilities: + +- create endpoint with `provider: "t3-tunnel"` +- surface tunnel status +- provide stable HTTPS URL +- use existing backend pairing/session auth +- never bypass server auth + +The tunnel fabric can later be Pipenet-derived, Tailscale-derived, or another reverse tunnel implementation. The rest of T3 Code should only see an `AdvertisedEndpoint`. + +## Security Checklist + +- Pairing tokens are short-lived and one-time. +- Generated hosted pairing links put tokens in the fragment. +- The backend remains the authorization boundary. +- Endpoint discovery never disables backend auth. +- Hosted app does not silently downgrade to HTTP. +- Tunnel/public endpoints require explicit user action. +- Client sessions remain revocable. +- Endpoint URLs and request logs must avoid recording pairing tokens. +- Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing. + +## Verification + +Each implementation PR should run: + +- `bun fmt` +- `bun lint` +- `bun typecheck` +- focused tests for changed backend/web behavior +- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` diff --git a/.plans/19-version-control-phase-1-vcs-driver-foundation.md b/.plans/19-version-control-phase-1-vcs-driver-foundation.md new file mode 100644 index 00000000000..e71c22d0ce3 --- /dev/null +++ b/.plans/19-version-control-phase-1-vcs-driver-foundation.md @@ -0,0 +1,216 @@ +# Version Control Phase 1: VCS Driver Foundation + +## Goal + +Introduce a provider-neutral VCS layer and rewrite the local Git implementation as an Effect-native driver. This phase should preserve user-visible behavior while replacing the Git-first service boundary with an abstraction that can support Git, Jujutsu, and later Sapling or another viable VCS. + +The existing `GitCore` implementation is a behavior reference and source of regression tests, not the target architecture. New code should follow the newer package style used by `effect-acp` and `effect-codex-app-server`: typed service tags, schema-backed tagged errors, scoped process usage, explicit decode boundaries, and no Promise-based process helper as the core execution primitive. + +## Scope + +- Add VCS-domain contracts in `packages/contracts/src/vcs.ts`. +- Add shared runtime parsing helpers in `packages/shared/src/vcs/*` only when they are useful to both server and web. +- Add server services under `apps/server/src/vcs`: + - `Services/VcsDriver.ts` + - `Services/VcsRepositoryResolver.ts` + - `Services/VcsProcess.ts` + - `Layers/GitVcsDriver.ts` + - `errors.ts` +- Migrate server callers from Git-specific terms where the operation is actually VCS-generic. +- Update active consumers to the new VCS APIs in the same phase; do not add backwards-compatible export shims. +- Leave source-control hosting providers out of this phase except for remote metadata needed to describe repository status. + +## Non-Goals + +- No GitLab, Azure DevOps, or GitHub provider rewrite yet. +- No Jujutsu driver yet, but every interface must be designed so a Jujutsu driver does not have to pretend to be Git. +- No T3 Review implementation yet. +- No broad UI redesign. + +## Driver Model + +Use provider-neutral nouns in new APIs: + +- `VcsDriver`: local repository mechanics. +- `RepositoryIdentity`: detected VCS kind, root path, common metadata path when available, remotes. +- `WorkingCopyStatus`: dirty state, changed files, aggregate insertions/deletions, current branch/bookmark/change name. +- `ChangeSet`: a committed or pending unit of change, not necessarily a Git commit. +- `RefName`: branch, bookmark, tag, or provider-specific ref. + +The initial driver capabilities should be explicit: + +```ts +export interface VcsDriverCapabilities { + readonly kind: "git" | "jj" | "sapling" | "unknown"; + readonly supportsWorktrees: boolean; + readonly supportsBookmarks: boolean; + readonly supportsAtomicSnapshot: boolean; + readonly supportsPushDefaultRemote: boolean; +} +``` + +Do not model Jujutsu as `GitCoreShape extends ...`. The Git driver can expose Git-specific implementation details internally, but the public VCS layer should describe operations by intent: + +- `detectRepository(cwd)` +- `status(cwd, options)` +- `listRefs(cwd, query/pagination)` +- `checkoutRef(cwd, ref)` +- `createRef(cwd, ref, from?)` +- `createWorkspace(cwd, ref, path?)` +- `removeWorkspace(path)` +- `prepareChangeContext(cwd, filePaths?)` +- `createChange(cwd, message, options)` +- `push(cwd, target?)` +- `rangeContext(cwd, base, head)` +- `listWorkspaceFiles(cwd, options)` + +## Effect Process Layer + +Create a small reusable `VcsProcess` service instead of using `runProcess`. + +Requirements: + +- Implement with `ChildProcess` and `ChildProcessSpawner` from `effect/unstable/process`. +- Support scoped acquisition/release for long-running commands and interruption. +- Support bounded stdout/stderr collection with truncation markers. + - DO not eagerly consume full stdout/stderr, return stream apis and expose helpers for consumers so we don't consume streams to memory unnecessarily... +- Support stdin. +- Support timeout through Effect scheduling/interruption, not ad-hoc timers. +- Stream output lines to progress callbacks as Effects. +- Return a typed `ProcessOutput` value for successful execution. +- Fail with typed errors, not generic thrown exceptions. + +Errors should be schema-backed tagged classes, for example: + +- `VcsProcessSpawnError` +- `VcsProcessExitError` +- `VcsProcessTimeoutError` +- `VcsOutputDecodeError` +- `VcsRepositoryDetectionError` +- `VcsUnsupportedOperationError` + +Every error should carry operation name, command display string, cwd when applicable, exit code when applicable, stderr/stdout tails when useful, and original cause where available. Override `message` for user readable messages that provides meaning and hints where appropriate. Errors are schema backed so the full error details will be persisted and serialized properly when stored to DB/Logfiles. + +## Git Driver Rewrite + +Rewrite Git support against `VcsProcess`. + +Carry forward current behavior from: + +- `apps/server/src/git/Layers/GitCore.ts` +- `apps/server/src/git/Layers/GitCore.test.ts` +- current Git status/branch/worktree contracts + +But split the implementation into smaller modules: + +- command execution and hardening config +- repository detection +- status parsing +- branch/ref parsing +- worktree operations +- commit/range context generation +- push/pull operations + +Keep parsing deterministic. Prefer Git porcelain formats, null-separated output, and schema decoding for JSON-like command output. Avoid regex parsing where Git gives a structured format. + +## Freshness and Local Caching + +Define freshness rules in the VCS layer before adding more providers. Local VCS status is cheap enough to refresh often; network-backed status is not. + +Treat these as live/local: + +- repository detection for the active cwd +- working copy dirty state +- staged/unstaged/untracked file summaries +- current branch/bookmark/change name +- local branch/bookmark lists +- local worktree/workspace lists + +These may run on user-visible polling, but should still be debounced and coalesced per repository root. Prefer filesystem-triggered invalidation where available, with a short fallback poll interval. Concurrent requests for the same repository/status shape should share one in-flight Effect. + +Treat these as cached or explicit-refresh only: + +- remote tracking branch refreshes +- ahead/behind counts that require network fetches +- default branch discovery from a remote provider +- remote branch lists beyond locally known refs + +The VCS driver should expose freshness metadata with status results: + +```ts +export interface VcsFreshness { + readonly source: "live-local" | "cached-local" | "cached-remote" | "explicit-remote"; + readonly observedAt: string; + readonly expiresAt?: string; +} +``` + +Remote refreshes should be opt-in per operation, for example `refresh: "local-only" | "allow-cached-remote" | "force-remote"`. The default for background status should be `local-only`. + +Use Effect `Cache` for repository identity and expensive local metadata: + +- key by resolved repository root plus VCS kind +- invalidate on cwd/root changes and workspace mutation operations +- use short TTLs for local status caches when filesystem events are unavailable +- never hide command failures behind stale values unless the caller explicitly accepts stale data + +## Cutover Policy + +Prefer direct migration and deletion over compatibility wrappers. + +Rules: + +- Update consumers to call `VcsDriver`/`VcsRepositoryResolver` directly as soon as the new API exists. +- Delete migrated `GitCore` service methods and tests in the same PR that moves their consumers. +- Do not keep backwards-compatible export shims, barrel aliases, or old service names for convenience. +- Transitional modules are allowed only when a caller group is too complex or risky to migrate in the same PR. +- Every transitional module must have a narrow owner, a removal checklist, and a test proving it delegates to the new implementation. +- No new feature work may depend on transitional modules. + +Expected transitional candidates: + +- The highest-level `GitManager` orchestration can be migrated in slices if doing the full Commit + PR flow in one PR is too risky. +- WebSocket payload compatibility can remain only where changing it would require a coordinated UI/server protocol migration. Internal server code should still use the new VCS contracts. + +## Tests + +Add integration-style tests with real temporary Git repositories for the new Git driver: + +- non-repository detection +- status for clean/dirty/untracked/staged states +- branch/ref list with pagination +- checkout/create branch +- worktree create/remove +- commit context generation with file filters +- commit creation with hook progress events +- push behavior against a local bare remote +- status polling does not perform remote network refresh by default +- concurrent duplicate status requests are coalesced +- bounded output/truncation +- timeout/interruption +- typed error shape for command failure and missing executable + +Move or duplicate only the tests needed to prove behavior, then delete the old service tests in the same migration slice. + +## Migration Steps + +1. Add `vcs` contracts and tagged errors. +2. Add `VcsProcess` and unit tests around process execution semantics. +3. Add `VcsDriver` and `VcsRepositoryResolver` service contracts. +4. Implement `GitVcsDriver` with real Git command integration tests. +5. Move `GitStatusBroadcaster` and branch/worktree flows to the VCS service directly. +6. Move commit/range/push callers to the VCS service directly. +7. Delete migrated `GitCore` internals and tests as each caller group moves. +8. Add a transitional adapter only for any remaining `GitManager` path that is explicitly too complex to cut over safely in one PR. +9. Remove every transitional adapter before starting Phase 2 unless the adapter is documented as blocking on the provider cutover. + +## Acceptance Criteria + +- Current Git branch/status/worktree/commit behavior remains intact. +- New Git implementation does not depend on `processRunner.ts`. +- New errors are typed and inspectable by tests. +- VCS interfaces contain no GitHub/GitLab/Azure concepts. +- Active consumers use the new VCS APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. +- Background status refresh is local-only by default and cannot hit provider rate limits. +- Jujutsu can be added by implementing a real driver instead of conforming to Git command semantics. +- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/20-version-control-phase-2-source-control-provider-foundation.md b/.plans/20-version-control-phase-2-source-control-provider-foundation.md new file mode 100644 index 00000000000..ac1186ba5f9 --- /dev/null +++ b/.plans/20-version-control-phase-2-source-control-provider-foundation.md @@ -0,0 +1,268 @@ +# Version Control Phase 2: Source Control Provider Foundation + +## Goal + +Introduce a pluggable source-control provider layer and rewrite GitHub support as an Effect-native provider. This phase should preserve the existing GitHub Commit + PR flow while making GitLab and Azure DevOps additive drivers rather than branches inside GitHub-oriented code. + +The existing `GitHubCli` service and GitHub-specific `GitManager` paths are behavior references. The new provider layer should use detailed tagged errors, schema decode boundaries, `effect/unstable/process`, capability flags, and provider-neutral change-request types. + +## Scope + +- Add provider-domain contracts in `packages/contracts/src/sourceControl.ts`. +- Add provider URL/reference parsing helpers in `packages/shared/src/sourceControl/*`. +- Add server services under `apps/server/src/sourceControl`: + - `Services/SourceControlProvider.ts` + - `Services/SourceControlProviderRegistry.ts` + - `Services/SourceControlProcess.ts` + - `Layers/GitHubSourceControlProvider.ts` + - `errors.ts` +- Migrate PR creation, PR lookup, default-branch lookup, clone URL lookup, and PR checkout through the provider layer. +- Update active consumers to the provider APIs directly; do not add backwards-compatible `GitHubCli` export shims. +- Keep GitHub as the only production provider at the end of this phase, but make GitLab and Azure implementation paths obvious and bounded. + +## Non-Goals + +- No GitLab implementation in this phase, except fixtures/contracts that prove the abstraction can represent merge requests. +- No Azure DevOps implementation in this phase, except URL/reference parser test cases if cheap. +- No in-app review UI yet. +- No hard dependency on one CLI forever. The first GitHub driver may use `gh`, but the interface should support REST/GraphQL implementations later. + +## Provider Model + +Use provider-neutral names: + +- `SourceControlProvider`: hosted repository and change-request mechanics. +- `ChangeRequest`: GitHub pull request, GitLab merge request, Azure pull request. +- `ChangeRequestThread`: review or discussion thread. +- `ChangeRequestComment`: top-level or inline comment. +- `ProviderRepository`: owner/project/repo identity plus clone URLs. + +Core provider operations: + +- `detectRemote(remoteUrl)` +- `checkAuth(cwd)` +- `getRepository(cwd | remoteUrl)` +- `getDefaultTargetRef(repository)` +- `listChangeRequests(repository, filters)` +- `getChangeRequest(repository, reference)` +- `createChangeRequest(repository, input)` +- `checkoutChangeRequest(cwd, changeRequest, options)` +- `getCloneUrls(repository)` + +Review-facing operations should be designed now, even if unimplemented: + +- `listReviewThreads(changeRequest)` +- `createReviewComment(changeRequest, input)` +- `replyToReviewThread(thread, input)` +- `resolveReviewThread(thread)` +- `submitReview(changeRequest, input)` + +Each operation should be guarded by capabilities: + +```ts +export interface SourceControlProviderCapabilities { + readonly kind: "github" | "gitlab" | "azure-devops" | "unknown"; + readonly supportsCreateChangeRequest: boolean; + readonly supportsCheckoutChangeRequest: boolean; + readonly supportsReviewThreads: boolean; + readonly supportsInlineComments: boolean; + readonly supportsDraftChangeRequests: boolean; +} +``` + +## Provider Registry + +Add a registry that resolves a provider from repository remotes and explicit user input. + +Rules: + +- Detection should be pure where possible and testable without spawning CLIs. +- Remote URL parsing belongs in `packages/shared`, not server-only provider layers. +- Unknown providers should return explicit unsupported-operation errors, not silently fall back to GitHub. +- Provider selection should be stable per operation and logged with enough context to debug bad remote detection. + +The registry should support multiple provider implementations at runtime, not a single dispatcher file with inline provider branches. + +## Rate Limits and Provider Caching + +Design the provider layer around a strict freshness budget. Provider API and CLI calls must not be part of frequent background polling unless the operation is explicitly marked safe and cached. + +Default behavior: + +- Pure URL/remote parsing is always live because it is local. +- Provider detection from local remotes is live-local. +- Authentication checks are cached. +- Repository metadata is cached. +- Default branch metadata is cached. +- Change-request lists are cached and refreshed on explicit user actions or coarse intervals. +- Full review threads, comments, file diffs, and timeline data are fetched only when the user opens the relevant review surface or explicitly refreshes it. +- Create/update operations invalidate affected cache keys immediately after success. + +The provider API should make freshness explicit: + +```ts +export interface SourceControlFreshness { + readonly source: "live-local" | "cached-provider" | "live-provider"; + readonly observedAt: string; + readonly expiresAt?: string; + readonly stale?: boolean; +} + +export type ProviderRefreshPolicy = + | "cache-first" + | "stale-while-revalidate" + | "force-refresh" + | "local-only"; +``` + +Every read operation that can touch a provider should accept a refresh policy. Background UI reads should default to `cache-first` or `stale-while-revalidate`; direct user actions like pressing refresh can use `force-refresh`. + +Use Effect `Cache` for provider data: + +- auth status: key by provider kind, hostname, workspace identity, and account if known; TTL around minutes, not seconds +- repository metadata/default branch: key by provider repository stable ID or normalized remote URL; TTL around tens of minutes +- change-request summary lists: key by provider repository, state/filter, source ref, target ref; short TTL with stale-while-revalidate +- individual change-request summaries: key by provider repository and provider CR ID; short TTL, invalidated after create/update/comment operations +- review threads/comments/diffs: key by provider CR ID and head SHA/version when available; fetch on demand for T3 Review + +Provider drivers should surface rate-limit signals when available: + +- remaining quota +- reset time +- retry-after duration +- whether the limit is primary, secondary/abuse, or unknown + +Rate-limit errors should be typed, retryable when the provider gives a reset/retry time, and visible enough for the UI to avoid repeatedly retrying a blocked operation. + +Avoid rate-limit footguns: + +- no provider calls from render loops or fast status polling +- no listing all PRs/MRs across all repos to infer one branch state +- no silent GitHub fallback for unknown providers +- no unbounded cache cardinality for branch names or free-form search queries +- no per-thread duplicate provider refresh when multiple views observe the same repository + +## GitHub Provider Rewrite + +Rewrite GitHub support as `GitHubSourceControlProvider`. + +Carry forward behavior from: + +- `apps/server/src/git/Layers/GitHubCli.ts` +- `apps/server/src/git/Layers/GitHubCli.test.ts` +- `apps/server/src/git/githubPullRequests.ts` +- GitHub-specific `GitManager` PR paths + +Implementation requirements: + +- Use `SourceControlProcess` built on `effect/unstable/process`, not `runProcess`. +- Decode `gh api` and `gh pr --json` responses with Effect Schema. +- Use typed errors for auth failure, missing CLI, command failure, output decode failure, unsupported reference, and provider mismatch. +- Keep stdout/stderr bounded. +- Avoid global mutable auth caches unless they are Effect `Cache` values with explicit keys, TTLs, and invalidation behavior. +- Parse provider rate-limit headers or CLI/API error payloads when available and map them to typed rate-limit errors. +- Keep GitHub nouns inside the GitHub driver; convert to `ChangeRequest` at the provider boundary. + +## GitManager Cutover + +Refactor `GitManager` so it coordinates three independent services: + +- `VcsDriver` for local repository mechanics. +- `SourceControlProviderRegistry` for hosted provider selection. +- `TextGeneration` for message/body generation. + +`GitManager` should stop depending directly on GitHub services. User-visible step labels should be provider-neutral unless the selected provider is known and the label is intentionally provider-specific. + +The Commit + PR flow should become: + +1. Resolve VCS repository and local status. +2. Resolve source-control provider from remotes. +3. Generate commit content through the existing text generation service. +4. Create local change through `VcsDriver`. +5. Push through `VcsDriver` or a narrow provider push helper only if the VCS requires provider-specific target syntax. +6. Generate change-request title/body. +7. Create the change request through `SourceControlProvider`. + +## Cutover Policy + +This phase should aggressively remove old GitHub-specific internals. + +Rules: + +- Move each active consumer directly to `SourceControlProviderRegistry` or a concrete provider test layer. +- Delete migrated `GitHubCli` methods, tests, and GitHub-specific helper exports in the same PR that moves their final consumer. +- Do not add compatibility export shims from `apps/server/src/git` to `apps/server/src/sourceControl`. +- Transitional modules are allowed only for a bounded `GitManager` slice that cannot move safely with the rest of the provider cutover. +- Every transitional module must have an owner comment, a removal checklist, and no public exports consumed by new code. +- Provider-neutral web parsing should replace GitHub-only parsing directly; do not keep parallel parser stacks unless a route still requires both during a single PR. + +## GitLab and Azure Readiness + +Use the triaged references as implementation inputs, not merge targets: + +- GitLab PR #592 is useful for `glab mr` command mapping and JSON normalization. +- Azure issue #1138 defines a good first Azure slice: remote/URL detection and change-request thread setup for same-repo URLs. + +The abstraction should let Phase 3 add: + +- `GitLabSourceControlProvider` using `glab`. +- `AzureDevOpsSourceControlProvider` using `az repos pr` or REST APIs. + +No provider should need to edit GitHub code to join the registry. + +## T3 Review Design Constraint + +Do not optimize only for creation/checkout. The provider layer must be able to support a future in-app review surface. + +That means contracts should include stable IDs and enough metadata for: + +- file-level diffs +- inline review threads +- resolved/unresolved state +- top-level discussion comments +- pending review submission +- provider URL back-links + +Provider-specific fields can live in a metadata bag, but core review behavior should not require the UI to know whether the backing service is GitHub, GitLab, or Azure DevOps. + +## Tests + +Add tests at three levels: + +- Pure parser tests for GitHub, GitLab, and Azure remote URLs and change-request references. +- Provider unit tests with fake `SourceControlProcess` output and schema decode failures. +- Integration-style GitHub CLI tests only where they can run hermetically or be skipped without hiding unit coverage. + +Required cases: + +- GitHub PR URL, number, and branch-ish references. +- GitLab MR URL/reference parsing. +- Azure DevOps PR URL parsing for same-repo URLs. +- unknown provider returns unsupported-operation errors. +- missing CLI and auth failures produce distinct typed errors. +- invalid CLI JSON fails at decode boundary with useful context. + +## Migration Steps + +1. Add `sourceControl` contracts and provider-neutral schemas. +2. Add shared remote/reference parser helpers and tests. +3. Add `SourceControlProcess` and provider errors. +4. Add provider registry with GitHub-only registration. +5. Implement `GitHubSourceControlProvider` from scratch against the new process layer. +6. Cut GitHub PR operations in `GitManager` over to the provider registry. +7. Replace web PR-reference parsing with provider-neutral parser output while keeping current GitHub UX. +8. Add provider cache metrics and tests for cache hit, stale refresh, invalidation, and rate-limit error mapping. +9. Delete the migrated `GitHubCli` implementation, tests, and GitHub-specific helper exports unless an explicit transitional checklist remains. + +## Acceptance Criteria + +- Existing GitHub Commit + PR and PR checkout flows still work. +- `GitManager` no longer imports or depends on `GitHubCli`. +- Active consumers use source-control provider APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. +- Source-control contracts can represent GitHub PRs, GitLab MRs, and Azure DevOps PRs. +- Unknown/unsupported providers fail explicitly and visibly. +- GitHub command execution does not depend on `processRunner.ts`. +- Background provider reads are cached/coalesced and do not consume provider API quota on every status refresh. +- Rate-limit responses become typed errors with retry/reset metadata where available. +- The provider API includes the review operations needed by future T3 Review work, even if they are capability-gated. +- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/README.md b/.plans/README.md index 7bb69a3b912..379158d4efd 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -10,3 +10,5 @@ 8. `08-precommit-format-and-lint.md` 9. `09-event-state-test-expansion.md` 10. `10-unify-process-session-abstraction.md` +19. `19-version-control-phase-1-vcs-driver-foundation.md` +20. `20-version-control-phase-2-source-control-provider-foundation.md` diff --git a/.vscode/settings.json b/.vscode/settings.json index 752d9a90713..8a1b614ddd9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,5 @@ "source.fixAll.oxc": "always" }, "oxc.unusedDisableDirectives": "warn", - "typescript.tsdk": "node_modules/typescript/lib" + "js/ts.tsdk.path": "node_modules/typescript/lib" } diff --git a/README.md b/README.md index d3a54a1b906..c439743cea5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents (currently Codex and Claude, more coming soon). +T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, and OpenCode, more coming soon). ## Installation > [!WARNING] -> T3 Code currently supports Codex and Claude. +> T3 Code currently supports Codex, Claude, and OpenCode. > Install and authenticate at least one provider before use: > -> - Codex: install [Codex CLI](https://github.com/openai/codex) and run `codex login` -> - Claude: install Claude Code and run `claude auth login` +> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` +> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` +> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` ### Run without installing @@ -49,6 +50,14 @@ Observability guide: [docs/observability.md](./docs/observability.md) ## If you REALLY want to contribute still.... read this first +Before local development, prepare the environment and install dependencies: + +```bash +# Optional: only needed if you use mise for dev tool management. +mise install +bun install . +``` + Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). diff --git a/REMOTE.md b/REMOTE.md index 30dc562792f..56510e62890 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -22,9 +22,41 @@ If you are already running the desktop app and want to make it reachable from ot 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. -3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). +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. +The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. + +When no user default is saved, the app uses the built-in LAN endpoint for pairing links when +available. You can set another endpoint as the default from the expanded endpoint list. + +- HTTPS/WSS-compatible endpoints work from `https://app.t3.codes`, but are not made the default + automatically. +- Non-loopback HTTP endpoints are useful for direct LAN pairing. +- Loopback-only endpoints are not useful for another device unless that device is the same machine. + +If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link. + +### Tailscale Endpoints + +When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. + +Depending on your Tailscale setup, this may include: + +- the machine's `100.x.y.z` Tailnet IP +- a MagicDNS name +- 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. + +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. + +For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A plain `http://100.x.y.z:3773` endpoint can still work from a desktop client or another browser page served over HTTP, but it will not work from the hosted HTTPS app because of browser mixed-content rules. + ### Option 2: Headless Server (CLI) Use this when you want to run the server without a GUI, for example on a remote machine over SSH. @@ -47,14 +79,79 @@ From there, connect from another device in either of these ways: - scan the QR code on your phone - in the desktop app, enter the full pairing URL - in the desktop app, enter the host and token separately +- in the hosted web app, open a hosted pairing URL when the backend is reachable over HTTPS Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. +For hosted web pairing over Tailscale HTTPS, opt in to Tailscale Serve: + +```bash +npx t3 serve --tailscale-serve +``` + +By default this configures Tailscale Serve on HTTPS port 443 and advertises +`https://machine.tailnet.ts.net/`. Advanced users can choose a different HTTPS port: + +```bash +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. +### Option 3: Desktop-Managed SSH Launch + +Use this when you want the desktop app to start or reuse T3 Code on another machine over SSH. + +1. Open **Settings** → **Connections**. +2. Under **Remote Environments**, choose **Add environment**. +3. Select the SSH launch flow. +4. Enter the SSH target, such as `user@example.com`. +5. Confirm the launch. The desktop app probes the host, starts or reuses a remote T3 server, opens a local port forward, and saves the environment. + +After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. The remote host still owns the actual T3 server, projects, files, git state, terminals, and provider sessions. + +SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments. + +#### SSH Launch Troubleshooting + +The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.t3/ssh-launch//`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop. + +The remote host must have a compatible Node.js runtime. T3 Code uses the server package's `engines.node` requirement: + +```text +^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` + +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: + +```bash +ssh user@example.com 'sh -lc "command -v node && node --version"' +``` + +If that does not print a compatible Node version, configure your version manager for non-interactive shells or install a compatible Node binary in one of the searched locations. For example, with nvm you may need a default alias: + +```bash +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. + +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. + ## How Pairing Works The remote device does not need a long-lived secret up front. @@ -67,6 +164,20 @@ Instead: After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. +## Hosted Web App Pairing + +The hosted web app at `https://app.t3.codes` can save a remote backend in browser local storage from a URL like: + +```text +https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE +``` + +Use hosted pairing when the backend is reachable from the browser over HTTPS/WSS. This includes a backend behind a trusted HTTPS tunnel or another HTTPS endpoint you operate. + +Do not use hosted pairing for plain HTTP LAN URLs such as `http://192.168.x.y:3773`. Browsers block an HTTPS page from connecting to an insecure HTTP or WS backend. For those endpoints, use the direct pairing URL shown by the desktop app or CLI from a client that can open that HTTP URL directly. + +Hosted pairing does not proxy traffic through T3 Code. The browser still connects directly to the backend URL in the pairing link. + ## Managing Access Later Use `t3 auth` to manage access after the initial pairing flow. @@ -84,4 +195,5 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference - Treat pairing URLs and pairing tokens like passwords. - Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. - Anyone with a valid pairing credential can create a session until that credential expires or is revoked. +- Hosted pairing links keep the credential in the URL hash so it is not sent to the hosted app server, but it can still be exposed through browser history, screenshots, logs, or copy/paste. - Use `t3 auth` to revoke credentials or sessions you no longer trust. diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore deleted file mode 100644 index 45b848af652..00000000000 --- a/apps/desktop/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist-electron/ -.electron-runtime/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a38ffd2df12..ead57b5cbf2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,26 +1,32 @@ { "name": "@t3tools/desktop", - "version": "0.0.17", + "version": "0.0.24", "private": true, - "main": "dist-electron/main.js", + "type": "module", + "main": "dist-electron/main.cjs", "scripts": { "dev": "bun run --parallel dev:bundle dev:electron", "dev:bundle": "tsdown --watch", - "dev:electron": "bun run scripts/dev-electron.mjs", + "dev:electron": "node scripts/dev-electron.mjs", "build": "tsdown", - "start": "bun run scripts/start-electron.mjs", + "start": "node scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { + "@effect/platform-node": "catalog:", + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/ssh": "workspace:*", + "@t3tools/tailscale": "workspace:*", "effect": "catalog:", - "electron": "40.6.0", + "electron": "41.5.0", "electron-updater": "^6.6.2" }, "devDependencies": { - "@t3tools/contracts": "workspace:*", - "@t3tools/shared": "workspace:*", + "@effect/language-service": "catalog:", + "@effect/vitest": "catalog:", "@types/node": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 7c0d55ac9a7..9a7e68dfbbb 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -17,12 +17,12 @@ if (!Number.isInteger(port) || port <= 0) { } const requiredFiles = [ - "dist-electron/main.js", - "dist-electron/preload.js", + "dist-electron/main.cjs", + "dist-electron/preload.cjs", "../server/dist/bin.mjs", ]; const watchedDirectories = [ - { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, + { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) }, { directory: "../server/dist", files: new Set(["bin.mjs"]) }, ]; const forcedShutdownTimeoutMs = 1_500; @@ -69,7 +69,7 @@ function startApp() { const app = spawn( resolveElectronPath(), - [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], + [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"], { cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index b8875b08abf..1453cbe666e 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -6,8 +6,8 @@ import { cpSync, existsSync, mkdirSync, + mkdtempSync, readFileSync, - readdirSync, rmSync, statSync, writeFileSync, @@ -19,10 +19,13 @@ import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; -const LAUNCHER_VERSION = 1; +const LAUNCHER_VERSION = 2; const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); +const repoRoot = resolve(desktopDir, "..", ".."); +const defaultIconPath = join(desktopDir, "resources", "icon.icns"); +const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); function setPlistString(plistPath, key, value) { const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { @@ -43,6 +46,68 @@ function setPlistString(plistPath, key, value) { throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); } +function runChecked(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status === 0) { + return; + } + + const details = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); +} + +function ensureDevelopmentIconIcns(runtimeDir) { + const generatedIconPath = join(runtimeDir, "icon-dev.icns"); + mkdirSync(runtimeDir, { recursive: true }); + + if (!existsSync(developmentMacIconPngPath)) { + return defaultIconPath; + } + + const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; + if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { + return generatedIconPath; + } + + const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); + const iconsetDir = join(iconsetRoot, "icon.iconset"); + mkdirSync(iconsetDir, { recursive: true }); + + try { + for (const size of [16, 32, 128, 256, 512]) { + runChecked("sips", [ + "-z", + String(size), + String(size), + developmentMacIconPngPath, + "--out", + join(iconsetDir, `icon_${size}x${size}.png`), + ]); + + const retinaSize = size * 2; + runChecked("sips", [ + "-z", + String(retinaSize), + String(retinaSize), + developmentMacIconPngPath, + "--out", + join(iconsetDir, `icon_${size}x${size}@2x.png`), + ]); + } + + runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]); + return generatedIconPath; + } catch (error) { + console.warn( + "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.", + error, + ); + return defaultIconPath; + } finally { + rmSync(iconsetRoot, { recursive: true, force: true }); + } +} + function patchMainBundleInfoPlist(appBundlePath, iconPath) { const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); @@ -55,40 +120,6 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { copyFileSync(iconPath, join(resourcesDir, "electron.icns")); } -function patchHelperBundleInfoPlists(appBundlePath) { - const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); - if (!existsSync(frameworksDir)) { - return; - } - - for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.endsWith(".app")) { - continue; - } - if (!entry.name.startsWith("Electron Helper")) { - continue; - } - - const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); - if (!existsSync(helperPlistPath)) { - continue; - } - - const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); - const helperName = suffix - ? `${APP_DISPLAY_NAME} Helper ${suffix}` - : `${APP_DISPLAY_NAME} Helper`; - const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); - const helperBundleId = helperIdSuffix - ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` - : `${APP_BUNDLE_ID}.helper`; - - setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); - setPlistString(helperPlistPath, "CFBundleName", helperName); - setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); - } -} - function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); @@ -102,7 +133,7 @@ function buildMacLauncher(electronBinaryPath) { const runtimeDir = join(desktopDir, ".electron-runtime"); const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); - const iconPath = join(desktopDir, "resources", "icon.icns"); + const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = join(runtimeDir, "metadata.json"); mkdirSync(runtimeDir, { recursive: true }); @@ -126,7 +157,6 @@ function buildMacLauncher(electronBinaryPath) { rmSync(targetAppBundlePath, { recursive: true, force: true }); cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); - patchHelperBundleInfoPlists(targetAppBundlePath); writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); return targetBinaryPath; @@ -140,5 +170,11 @@ export function resolveElectronPath() { return electronBinaryPath; } + // Dev launches do not need a renamed app bundle badly enough to risk breaking + // Electron helper resource lookup on macOS. + if (isDevelopment) { + return electronBinaryPath; + } + return buildMacLauncher(electronBinaryPath); } diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 883da7203a5..fdbe69b7780 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); -const mainJs = resolve(desktopDir, "dist-electron/main.js"); +const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index bf93adb6b0d..375dbfe575f 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts new file mode 100644 index 00000000000..b9817552969 --- /dev/null +++ b/apps/desktop/src/app/DesktopApp.ts @@ -0,0 +1,240 @@ +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +import * as NetService from "@t3tools/shared/Net"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; + +const DEFAULT_DESKTOP_BACKEND_PORT = 3773; +const MAX_TCP_PORT = 65_535; +const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; + +const makeDesktopRunId = Random.nextUUIDv4.pipe( + Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), +); + +class DesktopBackendPortUnavailableError extends Data.TaggedError( + "DesktopBackendPortUnavailableError", +)<{ + readonly startPort: number; + readonly maxPort: number; + readonly hosts: readonly string[]; +}> { + override get message() { + return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; + } +} + +class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( + "DesktopDevelopmentBackendPortRequiredError", +)<{}> { + override get message() { + return "T3CODE_PORT is required in desktop development."; + } +} + +const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = + DesktopObservability.makeComponentLogger("desktop-bootstrap"); + +const { logInfo: logStartupInfo, logError: logStartupError } = + DesktopObservability.makeComponentLogger("desktop-startup"); + +const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( + configuredPort: Option.Option, +) { + if (Option.isSome(configuredPort)) { + return { + port: configuredPort.value, + selectedByScan: false, + } as const; + } + + const net = yield* NetService.NetService; + for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { + let availableOnEveryHost = true; + + for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { + if (!(yield* net.canListenOnHost(port, host))) { + availableOnEveryHost = false; + break; + } + } + + if (availableOnEveryHost) { + return { + port, + selectedByScan: true, + } as const; + } + } + + return yield* new DesktopBackendPortUnavailableError({ + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + maxPort: MAX_TCP_PORT, + hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, + }); +}); + +const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( + stage: string, + error: unknown, +): Effect.fn.Return< + void, + never, + | DesktopLifecycle.DesktopShutdown + | DesktopState.DesktopState + | ElectronApp.ElectronApp + | ElectronDialog.ElectronDialog +> { + const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const state = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const message = error instanceof Error ? error.message : String(error); + const detail = + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; + yield* logStartupError("fatal startup error", { + stage, + message, + ...(detail.length > 0 ? { detail } : {}), + }); + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (!wasQuitting) { + yield* electronDialog.showErrorBox( + "T3 Code failed to start", + `Stage: ${stage}\n${message}${detail}`, + ); + } + yield* shutdown.request; + yield* electronApp.quit; +}); + +const fatalStartupCause = (stage: string, cause: Cause.Cause) => + handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); + +const bootstrap = Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const state = yield* DesktopState.DesktopState; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* logBootstrapInfo("bootstrap start"); + + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { + return yield* new DesktopDevelopmentBackendPortRequiredError(); + } + + const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); + const backendPort = backendPortSelection.port; + yield* logBootstrapInfo( + backendPortSelection.selectedByScan + ? "selected backend port via sequential scan" + : "using configured backend port", + { + port: backendPort, + ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), + }, + ); + + const settings = yield* desktopSettings.get; + if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { + yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { + mode: settings.serverExposureMode, + }); + } + const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); + const backendConfig = yield* serverExposure.backendConfig; + yield* logBootstrapInfo("bootstrap resolved backend endpoint", { + baseUrl: backendConfig.httpBaseUrl.href, + }); + if (serverExposureState.endpointUrl) { + yield* logBootstrapInfo("bootstrap enabled network access", { + endpointUrl: serverExposureState.endpointUrl, + }); + } else if (settings.serverExposureMode === "network-accessible") { + yield* logBootstrapWarning( + "bootstrap fell back to local-only because no advertised network host was available", + ); + } + + yield* installDesktopIpcHandlers; + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + + if (!(yield* Ref.get(state.quitting))) { + yield* backendManager.start; + yield* logBootstrapInfo("bootstrap backend start requested"); + } +}).pipe(Effect.withSpan("desktop.bootstrap")); + +const startup = Effect.gen(function* () { + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; + const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + const electronApp = yield* ElectronApp.ElectronApp; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + + yield* shellEnvironment.installIntoProcess; + const userDataPath = yield* appIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); + yield* desktopSettings.load; + + if (environment.platform === "linux") { + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + } + + yield* appIdentity.configure; + yield* lifecycle.register; + + yield* electronApp.whenReady.pipe( + Effect.withSpan("desktop.electron.whenReady"), + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), + ); + yield* logStartupInfo("app ready"); + yield* appIdentity.configure; + yield* applicationMenu.configure; + yield* electronProtocol.registerDesktopFileProtocol; + yield* updates.configure; + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); +}).pipe(Effect.withSpan("desktop.startup")); + +const scopedProgram = Effect.scoped( + Effect.gen(function* () { + const runId = yield* makeDesktopRunId; + yield* Effect.annotateLogsScoped({ scope: "desktop", runId }); + yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); + + const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + + yield* Effect.addFinalizer(() => + backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), + ); + + yield* startup; + yield* shutdown.awaitRequest; + }), +); + +export const program = scopedProgram.pipe(Effect.withSpan("desktop.app")); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts new file mode 100644 index 00000000000..f95fd1bef71 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -0,0 +1,176 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const defaultEnvironmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +type TestEnvironmentInput = Partial & { + readonly env?: Record; +}; + +interface ElectronAppCalls { + readonly setAboutPanelOptions: Array; + readonly setDockIcon: string[]; + readonly setName: string[]; +} + +const makeElectronAppLayer = (calls: ElectronAppCalls) => + 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: (name) => + Effect.sync(() => { + calls.setName.push(name); + }), + setAboutPanelOptions: (options) => + Effect.sync(() => { + calls.setAboutPanelOptions.push(options); + }), + setAppUserModelId: () => Effect.void, + setDesktopName: () => Effect.void, + setDockIcon: (iconPath) => + Effect.sync(() => { + calls.setDockIcon.push(iconPath); + }), + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void, + } satisfies ElectronApp.ElectronAppShape); + +const makeAssetsLayer = (png: Option.Option) => + Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.none(), + icns: Option.none(), + png, + }), + resolveResourcePath: () => Effect.succeed(Option.none()), + } satisfies DesktopAssets.DesktopAssetsShape); + +const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { + const { env, ...environmentOverrides } = overrides; + return DesktopEnvironment.layer({ + ...defaultEnvironmentInput, + ...environmentOverrides, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + ...env, + }), + ), + ), + ); +}; + +const withIdentity = ( + effect: Effect.Effect< + A, + E, + | R + | DesktopAppIdentity.DesktopAppIdentity + | DesktopEnvironment.DesktopEnvironment + | FileSystem.FileSystem + >, + input: { + readonly calls?: ElectronAppCalls; + readonly environment?: TestEnvironmentInput; + readonly legacyPathExists?: boolean; + readonly packageJson?: string; + readonly pngIconPath?: Option.Option; + } = {}, +) => { + const calls: ElectronAppCalls = input.calls ?? { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return effect.pipe( + Effect.provide( + DesktopAppIdentity.layer.pipe( + Layer.provideMerge( + FileSystem.layerNoop({ + exists: (path) => + Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), + readFileString: () => + Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), + }), + ), + Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())), + Layer.provideMerge(makeElectronAppLayer(calls)), + Layer.provideMerge(makeEnvironmentLayer(input.environment)), + ), + ), + ); +}; + +describe("DesktopAppIdentity", () => { + it.effect("keeps using the legacy userData path when it already exists", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + }), + { legacyPathExists: true }, + ), + ); + + it.effect("configures app identity from the environment commit override", () => { + const calls: ElectronAppCalls = { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; + + assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); + assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); + assert.deepEqual(calls.setDockIcon, ["/icon.png"]); + }), + { + calls, + environment: { + env: { + T3CODE_COMMIT_HASH: "0123456789abcdef", + }, + }, + pngIconPath: Option.some("/icon.png"), + }, + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts new file mode 100644 index 00000000000..c525d01d9d8 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -0,0 +1,128 @@ +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; +const COMMIT_HASH_DISPLAY_LENGTH = 12; + +const AppPackageMetadata = Schema.Struct({ + t3codeCommitHash: Schema.optional(Schema.String), +}); +const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); + +export interface DesktopAppIdentityShape { + readonly resolveUserDataPath: Effect.Effect; + readonly configure: Effect.Effect; +} + +export class DesktopAppIdentity extends Context.Service< + DesktopAppIdentity, + DesktopAppIdentityShape +>()("t3/desktop/AppIdentity") {} + +const normalizeCommitHash = (value: string): Option.Option => { + const trimmed = value.trim(); + return COMMIT_HASH_PATTERN.test(trimmed) + ? Option.some(trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase()) + : Option.none(); +}; + +const make = Effect.gen(function* () { + const assets = yield* DesktopAssets.DesktopAssets; + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const commitHashCache = yield* Ref.make>>(Option.none()); + + const resolveEmbeddedCommitHash = Effect.gen(function* () { + const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); + const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); + return yield* Option.match(raw, { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + decodeAppPackageMetadata(value).pipe( + Effect.map((parsed) => + Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ), + }); + }); + + const resolveAboutCommitHash = Effect.gen(function* () { + const cached = yield* Ref.get(commitHashCache); + if (Option.isSome(cached)) { + return cached.value; + } + + const override = Option.flatMap(environment.commitHashOverride, normalizeCommitHash); + if (Option.isSome(override)) { + yield* Ref.set(commitHashCache, Option.some(override)); + return override; + } + + if (!environment.isPackaged) { + const empty = Option.none(); + yield* Ref.set(commitHashCache, Option.some(empty)); + return empty; + } + + const commitHash = yield* resolveEmbeddedCommitHash; + yield* Ref.set(commitHashCache, Option.some(commitHash)); + return commitHash; + }); + + const resolveUserDataPath = Effect.gen(function* () { + const legacyPath = environment.path.join( + environment.appDataDirectory, + environment.legacyUserDataDirName, + ); + const legacyPathExists = yield* fileSystem + .exists(legacyPath) + .pipe(Effect.orElseSucceed(() => false)); + return legacyPathExists + ? legacyPath + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + + const configure = Effect.gen(function* () { + const commitHash = yield* resolveAboutCommitHash; + yield* electronApp.setName(environment.displayName); + yield* electronApp.setAboutPanelOptions({ + applicationName: environment.displayName, + applicationVersion: environment.appVersion, + version: Option.getOrElse(commitHash, () => "unknown"), + }); + + if (environment.platform === "win32") { + yield* electronApp.setAppUserModelId(environment.appUserModelId); + } + + if (environment.platform === "linux") { + yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); + } + + if (environment.platform === "darwin") { + const iconPaths = yield* assets.iconPaths; + yield* Option.match(iconPaths.png, { + onNone: () => Effect.void, + onSome: electronApp.setDockIcon, + }); + } + }).pipe(Effect.withSpan("desktop.appIdentity.configure")); + + return DesktopAppIdentity.of({ + resolveUserDataPath, + configure, + }); +}); + +export const layer = Layer.effect(DesktopAppIdentity, make); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts new file mode 100644 index 00000000000..60ff477d34f --- /dev/null +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -0,0 +1,85 @@ +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 DesktopEnvironment from "./DesktopEnvironment.ts"; + +export interface DesktopIconPaths { + readonly ico: Option.Option; + readonly icns: Option.Option; + readonly png: Option.Option; +} + +export interface DesktopAssetsShape { + readonly iconPaths: Effect.Effect; + readonly resolveResourcePath: (fileName: string) => Effect.Effect>; +} + +export class DesktopAssets extends Context.Service()( + "t3/desktop/Assets", +) {} + +const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* ( + fileName: string, +): Effect.fn.Return< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const candidates = environment.resolveResourcePathCandidates(fileName); + for (const candidate of candidates) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return Option.some(candidate); + } + } + return Option.none(); +}); + +const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( + ext: keyof DesktopIconPaths, +): Effect.fn.Return< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + const developmentDockIconPath = environment.developmentDockIconPath; + const developmentDockIconExists = yield* fileSystem + .exists(developmentDockIconPath) + .pipe(Effect.orElseSucceed(() => false)); + if (developmentDockIconExists) { + return Option.some(developmentDockIconPath); + } + } + + return yield* resolveResourcePath(`icon.${ext}`); +}); + +const make = Effect.gen(function* () { + const context = yield* Effect.context< + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment + >(); + const [ico, icns, png] = yield* Effect.all( + [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const, + { concurrency: "unbounded" }, + ); + const iconPaths = { ico, icns, png } satisfies DesktopIconPaths; + + return DesktopAssets.of({ + iconPaths: Effect.succeed(iconPaths), + resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")( + function* (fileName) { + return yield* resolveResourcePath(fileName).pipe(Effect.provide(context)); + }, + ), + }); +}); + +export const layer = Layer.effect(DesktopAssets, make); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts new file mode 100644 index 00000000000..a9218314018 --- /dev/null +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -0,0 +1,58 @@ +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Option from "effect/Option"; + +const trimNonEmptyOption = (value: string): Option.Option => { + const trimmed = value.trim(); + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); +}; + +const trimmedString = (name: string) => + Config.string(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption))); + +const optionalBoolean = (name: string) => + Config.boolean(name).pipe(Config.option, Config.map(Option.getOrElse(() => false))); + +const commaSeparatedStrings = (name: string) => + trimmedString(name).pipe( + Config.map( + Option.match({ + onNone: () => [], + onSome: (value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + }), + ), + ); + +const compactEnv = (env: Readonly>): Record => + Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + +export const DesktopConfig = Config.all({ + appDataDirectory: trimmedString("APPDATA"), + xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), + t3Home: trimmedString("T3CODE_HOME"), + devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), + devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), + configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), + commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), + desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), + desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), + otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), + otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( + Config.withDefault(10_000), + ), + appImagePath: trimmedString("APPIMAGE"), + disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), + mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), + mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe( + Config.withDefault(3000), + ), +}); + +export const layerTest = (env: Readonly>) => + ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts new file mode 100644 index 00000000000..427b8848833 --- /dev/null +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -0,0 +1,117 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; + +const defaultInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "0.0.22", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: false, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const makeEnvironmentLayer = ( + overrides: Partial = {}, + env: Record = {}, +) => + DesktopEnvironment.layer({ + ...defaultInput, + ...overrides, + }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env)))); + +const makeEnvironment = ( + overrides: Partial = {}, + env: Record = {}, +) => + Effect.gen(function* () { + return yield* DesktopEnvironment.DesktopEnvironment; + }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env))); + +describe("DesktopEnvironment", () => { + it.effect("derives state paths and development identity inside Effect", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + {}, + { + T3CODE_HOME: " /tmp/t3 ", + T3CODE_COMMIT_HASH: " 0123456789abcdef ", + T3CODE_PORT: "4949", + VITE_DEV_SERVER_URL: "http://localhost:5173", + T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", + T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", + T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", + }, + ); + + assert.equal(environment.isDevelopment, true); + assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); + assert.equal(environment.baseDir, "/tmp/t3"); + assert.equal(environment.stateDir, "/tmp/t3/dev"); + assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json"); + assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json"); + assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); + assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); + assert.equal(environment.logDir, "/tmp/t3/dev/logs"); + assert.equal(environment.rootDir, "/repo"); + assert.equal(environment.appRoot, "/repo"); + assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); + assert.equal(environment.backendCwd, "/repo"); + assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); + assert.equal(environment.linuxWmClass, "t3code-dev"); + assert.deepEqual( + Option.map(environment.devServerUrl, (url) => url.href), + Option.some("http://localhost:5173/"), + ); + assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); + assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); + assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); + assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); + assert.equal(environment.otlpExportIntervalMs, 2500); + }), + ); + + it.effect("derives production state paths under userdata", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + {}, + { + T3CODE_HOME: "/tmp/t3", + }, + ); + + assert.equal(environment.isDevelopment, false); + assert.equal(environment.stateDir, "/tmp/t3/userdata"); + assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + }), + ); + + it.effect("resolves picker defaults without nullish sentinels", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment(); + + assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: " " }), + Option.none(), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~" }), + Option.some("/Users/alice"), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), + Option.some("/Users/alice/project"), + ); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts new file mode 100644 index 00000000000..a5212f25358 --- /dev/null +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -0,0 +1,249 @@ +import type { + DesktopAppBranding, + DesktopAppStageLabel, + DesktopRuntimeArch, + DesktopRuntimeInfo, +} from "@t3tools/contracts"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { + type DesktopSettings, + resolveDefaultDesktopSettings, +} from "../settings/DesktopAppSettings.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; + +export interface MakeDesktopEnvironmentInput { + readonly dirname: string; + readonly homeDirectory: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly appVersion: string; + readonly appPath: string; + readonly isPackaged: boolean; + readonly resourcesPath: string; + readonly runningUnderArm64Translation: boolean; +} + +export interface DesktopEnvironmentShape { + readonly path: Path.Path; + readonly dirname: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly isPackaged: boolean; + readonly isDevelopment: boolean; + readonly appVersion: string; + readonly appPath: string; + readonly resourcesPath: string; + readonly homeDirectory: string; + readonly appDataDirectory: string; + readonly baseDir: string; + readonly stateDir: string; + readonly desktopSettingsPath: string; + readonly clientSettingsPath: string; + readonly savedEnvironmentRegistryPath: string; + readonly serverSettingsPath: string; + readonly logDir: string; + readonly rootDir: string; + readonly appRoot: string; + readonly backendEntryPath: string; + readonly backendCwd: string; + readonly preloadPath: string; + readonly appUpdateYmlPath: string; + readonly devServerUrl: Option.Option; + readonly devRemoteT3ServerEntryPath: Option.Option; + readonly configuredBackendPort: Option.Option; + readonly commitHashOverride: Option.Option; + readonly otlpTracesUrl: Option.Option; + readonly otlpExportIntervalMs: number; + readonly branding: DesktopAppBranding; + readonly displayName: string; + readonly appUserModelId: string; + readonly linuxDesktopEntryName: string; + readonly linuxWmClass: string; + readonly userDataDirName: string; + readonly legacyUserDataDirName: string; + readonly defaultDesktopSettings: DesktopSettings; + readonly runtimeInfo: DesktopRuntimeInfo; + readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; + readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; + readonly developmentDockIconPath: string; +} + +export class DesktopEnvironment extends Context.Service< + DesktopEnvironment, + DesktopEnvironmentShape +>()("t3/desktop/Environment") {} + +const APP_BASE_NAME = "T3 Code"; + +function resolveDesktopAppStageLabel(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppStageLabel { + if (input.isDevelopment) { + return "Dev"; + } + + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; +} + +function resolveDesktopAppBranding(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppBranding { + const stageLabel = resolveDesktopAppStageLabel(input); + return { + baseName: APP_BASE_NAME, + stageLabel, + displayName: `${APP_BASE_NAME} (${stageLabel})`, + }; +} + +function normalizeDesktopArch(arch: string): DesktopRuntimeArch { + if (arch === "arm64") return "arm64"; + if (arch === "x64") return "x64"; + return "other"; +} + +function resolveDesktopRuntimeInfo(input: { + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly runningUnderArm64Translation: boolean; +}): DesktopRuntimeInfo { + const appArch = normalizeDesktopArch(input.processArch); + + if (input.platform !== "darwin") { + return { + hostArch: appArch, + appArch, + runningUnderArm64Translation: false, + }; + } + + const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; + + return { + hostArch, + appArch, + runningUnderArm64Translation: input.runningUnderArm64Translation, + }; +} + +const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( + input: MakeDesktopEnvironmentInput, +): Effect.fn.Return { + const path = yield* Path.Path; + const config = yield* DesktopConfig.DesktopConfig; + const homeDirectory = input.homeDirectory; + const devServerUrl = config.devServerUrl; + const isDevelopment = Option.isSome(devServerUrl); + const appDataDirectory = + input.platform === "win32" + ? Option.getOrElse(config.appDataDirectory, () => + path.join(homeDirectory, "AppData", "Roaming"), + ) + : input.platform === "darwin" + ? path.join(homeDirectory, "Library", "Application Support") + : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); + const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); + const rootDir = path.resolve(input.dirname, "../../.."); + const appRoot = input.isPackaged ? input.appPath : rootDir; + const branding = resolveDesktopAppBranding({ + isDevelopment, + appVersion: input.appVersion, + }); + const displayName = branding.displayName; + const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); + const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; + const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const resourcesPath = input.resourcesPath; + + return DesktopEnvironment.of({ + path, + dirname: input.dirname, + platform: input.platform, + processArch: input.processArch, + isPackaged: input.isPackaged, + isDevelopment, + appVersion: input.appVersion, + appPath: input.appPath, + resourcesPath, + homeDirectory, + appDataDirectory, + baseDir, + stateDir, + desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), + clientSettingsPath: path.join(stateDir, "client-settings.json"), + savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), + serverSettingsPath: path.join(stateDir, "settings.json"), + logDir: path.join(stateDir, "logs"), + rootDir, + appRoot, + backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), + backendCwd: input.isPackaged ? homeDirectory : appRoot, + preloadPath: path.join(input.dirname, "preload.cjs"), + appUpdateYmlPath: input.isPackaged + ? path.join(resourcesPath, "app-update.yml") + : path.join(input.appPath, "dev-app-update.yml"), + devServerUrl, + devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, + configuredBackendPort: config.configuredBackendPort, + commitHashOverride: config.commitHashOverride, + otlpTracesUrl: config.otlpTracesUrl, + otlpExportIntervalMs: config.otlpExportIntervalMs, + branding, + displayName, + appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", + linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + userDataDirName, + legacyUserDataDirName, + defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion), + runtimeInfo: resolveDesktopRuntimeInfo({ + platform: input.platform, + processArch: input.processArch, + runningUnderArm64Translation: input.runningUnderArm64Translation, + }), + resolvePickFolderDefaultPath: (rawOptions) => { + if (typeof rawOptions !== "object" || rawOptions === null) { + return Option.none(); + } + + const { initialPath } = rawOptions as { initialPath?: unknown }; + if (typeof initialPath !== "string") { + return Option.none(); + } + + const trimmedPath = initialPath.trim(); + if (trimmedPath.length === 0) { + return Option.none(); + } + + if (trimmedPath === "~") { + return Option.some(homeDirectory); + } + + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { + return Option.some(path.join(homeDirectory, trimmedPath.slice(2))); + } + + return Option.some(path.resolve(trimmedPath)); + }, + resolveResourcePathCandidates: (fileName) => [ + path.join(input.dirname, "../resources", fileName), + path.join(input.dirname, "../prod-resources", fileName), + path.join(resourcesPath, "resources", fileName), + path.join(resourcesPath, fileName), + ], + developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), + }); +}); + +export const layer = (input: MakeDesktopEnvironmentInput) => + Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts new file mode 100644 index 00000000000..b9a7636a411 --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -0,0 +1,233 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +export interface DesktopShutdownShape { + readonly request: Effect.Effect; + readonly awaitRequest: Effect.Effect; + readonly markComplete: Effect.Effect; + readonly awaitComplete: Effect.Effect; + readonly isComplete: Effect.Effect; +} + +export class DesktopShutdown extends Context.Service()( + "t3/desktop/Shutdown", +) {} + +const makeShutdown = Effect.gen(function* () { + const requested = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const completedRef = yield* Ref.make(false); + + return DesktopShutdown.of({ + request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), + awaitRequest: Deferred.await(requested), + markComplete: Ref.set(completedRef, true).pipe( + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.asVoid, + ), + awaitComplete: Deferred.await(completed), + isComplete: Ref.get(completedRef), + }); +}); + +export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); + +export type DesktopLifecycleRuntimeServices = + | DesktopEnvironment.DesktopEnvironment + | DesktopShutdown + | DesktopState.DesktopState + | DesktopWindow.DesktopWindow + | ElectronApp.ElectronApp + | ElectronTheme.ElectronTheme; + +export interface DesktopLifecycleShape { + readonly relaunch: ( + reason: string, + ) => Effect.Effect; + readonly register: Effect.Effect; +} + +export class DesktopLifecycle extends Context.Service()( + "t3/desktop/Lifecycle", +) {} + +const { logInfo: logLifecycleInfo, logError: logLifecycleError } = + DesktopObservability.makeComponentLogger("desktop-lifecycle"); + +function addScopedListener>( + target: unknown, + eventName: string, + listener: (...args: Args) => void, +): Effect.Effect { + const eventTarget = target as { + on: (eventName: string, listener: (...args: Array) => void) => unknown; + removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + eventTarget.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); +} + +const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( + function* (): Effect.fn.Return { + const shutdown = yield* DesktopShutdown; + yield* shutdown.request; + yield* shutdown.awaitComplete; + }, +); + +function handleBeforeQuit( + event: Electron.Event, + runEffect: (effect: Effect.Effect) => Promise, + allowQuit: () => boolean, + markQuitAllowed: () => void, +): void { + if (allowQuit()) { + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logLifecycleInfo("before-quit received"); + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), + ); + return; + } + + event.preventDefault(); + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logLifecycleInfo("before-quit received"); + yield* requestDesktopShutdownAndWait(); + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), + ).finally(() => { + markQuitAllowed(); + void runEffect( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.quit; + }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), + ); + }); +} + +function quitFromSignal( + signal: "SIGINT" | "SIGTERM", + runEffect: (effect: Effect.Effect) => Promise, +): void { + void runEffect( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ signal }); + const electronApp = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (wasQuitting) return; + yield* logLifecycleInfo("process signal received", { signal }); + yield* requestDesktopShutdownAndWait(); + yield* electronApp.quit; + }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), + ); +} + +export const layer = Layer.succeed( + DesktopLifecycle, + DesktopLifecycle.of({ + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const state = yield* DesktopState.DesktopState; + yield* logLifecycleInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => + logLifecycleError("desktop relaunch failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.forkDetach, + Effect.asVoid, + ); + }), + register: Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = Effect.runPromiseWith(context); + let quitAllowed = false; + yield* electronTheme.onUpdated(() => { + void runEffect( + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), + ); + }); + yield* electronApp.on("before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* electronApp.on("activate", () => { + void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); + }); + yield* electronApp.on("window-all-closed", () => { + void runEffect( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* app.quit; + } + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), + ); + }); + + if (environment.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); + }); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); + }); + } + }).pipe(Effect.withSpan("desktop.lifecycle.register")), + }), +); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts new file mode 100644 index 00000000000..a78de48d5e1 --- /dev/null +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -0,0 +1,162 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; + +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const decodeDesktopBackendChildLogRecord = Schema.decodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + +const TraceRecordLine = Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + events: Schema.Array( + Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + }), + ), +}); + +const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); + +const environmentInput = (baseDir: string) => + ({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: baseDir, + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, + }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const makeEnvironmentLayer = (baseDir: string) => + 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", + }), + ), + ), + ); + +describe("DesktopObservability", () => { + it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const tracePath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop.trace.ndjson"); + }).pipe(Effect.provide(environmentLayer)); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop-main.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ "desktop.test": true }); + yield* Effect.logInfo("desktop trace event"); + }).pipe( + Effect.withSpan("desktop-observability-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + const records = (yield* fileSystem.readFileString(tracePath)) + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeTraceRecordLine(line)); + const record = records.find((entry) => entry.name === "desktop-observability-test"); + + assert.notEqual(record, undefined); + if (!record) { + return; + } + assert.equal(record.attributes["desktop.test"], true); + assert.equal( + record.events.some((event) => event.name === "desktop trace event"), + true, + ); + assert.isFalse(yield* fileSystem.exists(logPath)); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + + it.effect("persists backend child output as structured JSON records in development", () => + 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 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 outputLog = yield* DesktopObservability.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ + phase: "START", + details: "pid=123 port=3773 cwd=/repo", + }); + yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); + }).pipe( + Effect.annotateLogs({ runId: "test-run" }), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ); + + const log = yield* fileSystem.readFileString(logPath); + const lines = log.trimEnd().split("\n"); + const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); + const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); + + 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.phase, "START"); + assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); + + assert.equal(output.message, "backend child process output"); + assert.equal(output.level, "INFO"); + assert.equal(output.annotations.component, "desktop-backend-child"); + assert.equal(output.annotations.runId, "test-run"); + assert.equal(output.annotations.stream, "stdout"); + assert.equal(output.annotations.text, "hello server\n"); + }).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 new file mode 100644 index 00000000000..4eeb76bd62a --- /dev/null +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -0,0 +1,395 @@ +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Tracer from "effect/Tracer"; +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; + +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; + +export interface RotatingLogFileWriter { + readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; + readonly writeText: (chunk: string) => Effect.Effect; +} + +export interface DesktopBackendOutputLogShape { + readonly writeSessionBoundary: (input: { + readonly phase: "START" | "END"; + readonly details: string; + }) => Effect.Effect; + readonly writeOutputChunk: ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, + ) => Effect.Effect; +} + +export class DesktopBackendOutputLog extends Context.Service< + DesktopBackendOutputLog, + DesktopBackendOutputLogShape +>()("t3/desktop/BackendOutputLog") {} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export type DesktopLogAnnotations = Record; + +export interface DesktopComponentLogger { + readonly annotate: ( + effect: Effect.Effect, + annotations?: DesktopLogAnnotations, + ) => Effect.Effect; + readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; + readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; + readonly logWarning: ( + message: string, + annotations?: DesktopLogAnnotations, + ) => Effect.Effect; + readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; +} + +export function makeComponentLogger(component: string): DesktopComponentLogger { + const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) => + effect.pipe( + Effect.annotateLogs({ + component, + ...annotations, + }), + ); + + return { + annotate, + logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations), + logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), + logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), + logError: (message, annotations) => annotate(Effect.logError(message), annotations), + }; +} + +class DesktopLogFileWriterConfigurationError extends Data.TaggedError( + "DesktopLogFileWriterConfigurationError", +)<{ + readonly option: "maxBytes" | "maxFiles"; + readonly value: number; +}> { + override get message() { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +type DesktopLogFileWriterError = + | DesktopLogFileWriterConfigurationError + | PlatformError.PlatformError; + +const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); + +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + +const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, +}; + +const currentDesktopRunId = Effect.gen(function* () { + const annotations = yield* References.CurrentLogAnnotations; + const runId = annotations.runId; + return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; +}); + +const refreshFileSize = ( + fileSystem: FileSystem.FileSystem, + filePath: string, +): Effect.Effect => + fileSystem.stat(filePath).pipe( + Effect.map((stat) => Number(stat.size)), + Effect.orElseSucceed(() => 0), + ); + +const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { + readonly filePath: string; + readonly maxBytes?: number; + readonly maxFiles?: number; +}): Effect.fn.Return< + RotatingLogFileWriter, + DesktopLogFileWriterError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; + const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; + const directory = path.dirname(input.filePath); + const baseName = path.basename(input.filePath); + + if (maxBytes < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxBytes", + value: maxBytes, + }); + } + if (maxFiles < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxFiles", + value: maxFiles, + }); + } + + yield* fileSystem.makeDirectory(directory, { recursive: true }); + + const withSuffix = (index: number) => `${input.filePath}.${index}`; + const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); + const mutex = yield* Semaphore.make(1); + + const pruneOverflowBackups = Effect.gen(function* () { + const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + if (!entry.startsWith(`${baseName}.`)) continue; + const suffix = Number(entry.slice(baseName.length + 1)); + if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; + yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); + } + }); + + const rotate = Effect.gen(function* () { + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); + for (let index = maxFiles - 1; index >= 1; index -= 1) { + const source = withSuffix(index); + const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); + if (sourceExists) { + yield* fileSystem.rename(source, withSuffix(index + 1)); + } + } + const currentExists = yield* fileSystem + .exists(input.filePath) + .pipe(Effect.orElseSucceed(() => false)); + if (currentExists) { + yield* fileSystem.rename(input.filePath, withSuffix(1)); + } + yield* Ref.set(currentSize, 0); + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ); + + const writeBytes = (chunk: Uint8Array): Effect.Effect => { + if (chunk.byteLength === 0) return Effect.void; + + return mutex.withPermits(1)( + Effect.gen(function* () { + const beforeSize = yield* Ref.get(currentSize); + if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { + yield* rotate; + } + + yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); + const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; + yield* Ref.set(currentSize, afterSize); + + if (afterSize > maxBytes) { + yield* rotate; + } + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ), + ); + }; + + yield* pruneOverflowBackups; + + return { + writeBytes, + writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), + } satisfies RotatingLogFileWriter; +}); + +const readPersistedOtlpTracesUrl: Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + if (Option.isNone(raw)) { + return Option.none(); + } + + const parsed = parsePersistedServerObservabilitySettings(raw.value); + return Option.fromNullishOr(parsed.otlpTracesUrl); +}); + +const resolveOtlpTracesUrl = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (Option.isSome(environment.otlpTracesUrl)) { + return environment.otlpTracesUrl; + } + return yield* readPersistedOtlpTracesUrl; +}); + +const writeDevelopmentConsoleOutput = ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, +): Effect.Effect => + Effect.sync(() => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }).pipe(Effect.ignore); + +const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( + function* ( + logFile: RotatingLogFileWriter, + input: { + readonly message: string; + readonly level: "INFO" | "ERROR"; + readonly annotations: Record; + }, + ): Effect.fn.Return { + return yield* Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const encoded = yield* encodeDesktopBackendChildLogRecord({ + message: input.message, + level: input.level, + timestamp, + annotations: input.annotations, + spans: {}, + fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, + }); + yield* logFile.writeText(`${encoded}\n`); + }).pipe(Effect.ignore({ log: true })); + }, +); + +const backendOutputLogLayer = Layer.effect( + DesktopBackendOutputLog, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + + const writer = yield* makeRotatingLogFileWriter({ + filePath: environment.path.join(environment.logDir, "server-child.log"), + }).pipe(Effect.option); + + return Option.match(writer, { + onNone: () => DesktopBackendOutputLogNoop, + onSome: (logFile) => + ({ + writeSessionBoundary: Effect.fn( + "desktop.observability.backendOutput.writeSessionBoundary", + )(function* ({ phase, details }) { + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: `backend child process session ${phase.toLowerCase()}`, + level: "INFO", + annotations: { + component: "desktop-backend-child", + runId, + phase, + details: sanitizeLogValue(details), + }, + }); + }), + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( + function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId, + stream: streamName, + text: textDecoder.decode(chunk), + }, + }); + }, + ), + }) satisfies DesktopBackendOutputLogShape, + }); + }), +); + +const desktopLoggerLayer = Layer.mergeAll( + Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), +); + +const tracerLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const otlpTracesUrl = yield* resolveOtlpTracesUrl; + const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, + }); + const delegate = Option.isNone(otlpTracesUrl) + ? undefined + : yield* OtlpTracer.make({ + url: otlpTracesUrl.value, + exportInterval: `${environment.otlpExportIntervalMs} millis`, + resource: { + serviceName: "desktop", + attributes: { + "service.runtime": "desktop", + "service.mode": environment.isDevelopment ? "development" : "packaged", + }, + }, + }); + const tracer = yield* makeLocalFileTracer({ + filePath: tracePath, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, + sink, + ...(delegate ? { delegate } : {}), + }); + + return Layer.succeed(Tracer.Tracer, tracer); + }), +).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); + +export const layer = Layer.mergeAll( + backendOutputLogLayer, + desktopLoggerLayer, + tracerLayer, + Layer.succeed(Tracer.MinimumTraceLevel, "Info"), + Layer.succeed(References.TracerTimingEnabled, true), +); diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts new file mode 100644 index 00000000000..43960ada65f --- /dev/null +++ b/apps/desktop/src/app/DesktopState.ts @@ -0,0 +1,21 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +export interface DesktopStateShape { + readonly backendReady: Ref.Ref; + readonly quitting: Ref.Ref; +} + +export class DesktopState extends Context.Service()( + "t3/desktop/State", +) {} + +export const layer = Layer.effect( + DesktopState, + Effect.all({ + backendReady: Ref.make(false), + quitting: Ref.make(false), + }), +); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts new file mode 100644 index 00000000000..96e56a87c9d --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -0,0 +1,195 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; + +const PersistedServerObservabilitySettingsDocument = Schema.Struct({ + observability: Schema.Struct({ + otlpTracesUrl: Schema.String, + otlpMetricsUrl: Schema.String, + }), +}); + +const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( + Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), +); + +const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 4888, + bindHost: "0.0.0.0", + httpBaseUrl: new URL("http://127.0.0.1:4888"), + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.succeed([]), +} satisfies DesktopServerExposure.DesktopServerExposureShape); + +function makeEnvironmentLayer( + baseDir: string, + options?: { + readonly isPackaged?: boolean; + readonly devServerUrl?: string; + }, +) { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: options?.isPackaged ?? true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: baseDir, + T3CODE_PORT: "9999", + T3CODE_MODE: "desktop", + T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", + VITE_DEV_SERVER_URL: options?.devServerUrl, + }), + ), + ), + ); +} + +const withHarness = ( + effect: Effect.Effect< + A, + E, + | R + | DesktopEnvironment.DesktopEnvironment + | FileSystem.FileSystem + | DesktopBackendConfiguration.DesktopBackendConfiguration + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + return yield* effect.pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +describe("DesktopBackendConfiguration", () => { + it.effect("resolves backend start config with a stable scoped bootstrap token", () => + withHarness( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + const first = yield* configuration.resolve; + const second = yield* configuration.resolve; + + assert.equal(first.executablePath, process.execPath); + assert.equal(first.entryPath, environment.backendEntryPath); + assert.equal(first.cwd, environment.backendCwd); + assert.equal(first.captureOutput, true); + assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); + assert.isUndefined(first.env.T3CODE_PORT); + assert.isUndefined(first.env.T3CODE_MODE); + assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); + + assert.equal(first.bootstrap.mode, "desktop"); + assert.equal(first.bootstrap.noBrowser, true); + assert.equal(first.bootstrap.port, 4888); + assert.equal(first.bootstrap.host, "0.0.0.0"); + assert.equal(first.bootstrap.t3Home, environment.baseDir); + assert.equal(first.bootstrap.tailscaleServeEnabled, true); + assert.equal(first.bootstrap.tailscaleServePort, 8443); + assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i); + assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken); + }), + ), + ); + + it.effect("includes persisted backend observability endpoints when present", () => + withHarness( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { + recursive: true, + }); + yield* fileSystem.writeFileString( + environment.serverSettingsPath, + yield* encodePersistedServerObservabilitySettingsDocument({ + observability: { + otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", + otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", + }, + }), + ); + + const config = yield* configuration.resolve; + assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); + assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + }), + ), + ); + + it.effect("omits backend observability endpoints when settings are missing", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolve; + + assert.isUndefined(config.bootstrap.otlpTracesUrl); + assert.isUndefined(config.bootstrap.otlpMetricsUrl); + }), + ), + ); + + it.effect("captures backend output in development so child process logs can be persisted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolve; + assert.equal(config.captureOutput, true); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + isPackaged: false, + devServerUrl: "http://127.0.0.1:5733", + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts new file mode 100644 index 00000000000..42e4ada438b --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -0,0 +1,170 @@ +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +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 Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; + +export interface DesktopBackendConfigurationShape { + readonly resolve: Effect.Effect; +} + +export class DesktopBackendConfiguration extends Context.Service< + DesktopBackendConfiguration, + DesktopBackendConfigurationShape +>()("t3/desktop/BackendConfiguration") {} + +interface BackendObservabilitySettings { + readonly otlpTracesUrl: Option.Option; + readonly otlpMetricsUrl: Option.Option; +} + +const emptyBackendObservabilitySettings: BackendObservabilitySettings = { + otlpTracesUrl: Option.none(), + otlpMetricsUrl: Option.none(), +}; + +const DESKTOP_BACKEND_ENV_NAMES = [ + "T3CODE_PORT", + "T3CODE_MODE", + "T3CODE_NO_BROWSER", + "T3CODE_HOST", + "T3CODE_DESKTOP_WS_URL", + "T3CODE_DESKTOP_LAN_ACCESS", + "T3CODE_DESKTOP_LAN_HOST", + "T3CODE_DESKTOP_HTTPS_ENDPOINTS", + "T3CODE_TAILSCALE_SERVE", + "T3CODE_TAILSCALE_SERVE_PORT", +] as const; + +const backendChildEnvPatch = (): Record => + Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); + +const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( + "desktop-backend-configuration", +); + +const readPersistedBackendObservabilitySettings: Effect.Effect< + BackendObservabilitySettings, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const exists = yield* fileSystem + .exists(environment.serverSettingsPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return emptyBackendObservabilitySettings; + } + + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + if (Option.isNone(raw)) { + yield* logBackendConfigurationWarning( + "failed to read persisted backend observability settings", + ); + return emptyBackendObservabilitySettings; + } + + const parsed = parsePersistedServerObservabilitySettings(raw.value); + return { + otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), + otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), + }; +}); + +const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootstrapToken")( + function* (tokenRef: Ref.Ref>) { + const existing = yield* Ref.get(tokenRef); + if (Option.isSome(existing)) { + return existing.value; + } + + let token = ""; + while (token.length < 48) { + token += (yield* Random.nextUUIDv4).replace(/-/g, ""); + } + token = token.slice(0, 48); + yield* Ref.set(tokenRef, Option.some(token)); + return token; + }, +); + +const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( + function* (input: { + readonly bootstrapToken: string; + readonly observabilitySettings: BackendObservabilitySettings; + }): Effect.fn.Return< + DesktopBackendManager.DesktopBackendStartConfig, + never, + DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure + > { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const backendExposure = yield* serverExposure.backendConfig; + + return { + executablePath: process.execPath, + entryPath: environment.backendEntryPath, + cwd: environment.backendCwd, + env: { + ...backendChildEnvPatch(), + ELECTRON_RUN_AS_NODE: "1", + }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: backendExposure.port, + t3Home: environment.baseDir, + host: backendExposure.bindHost, + desktopBootstrapToken: input.bootstrapToken, + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, + tailscaleServePort: backendExposure.tailscaleServePort, + ...Option.match(input.observabilitySettings.otlpTracesUrl, { + onNone: () => ({}), + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), + }), + ...Option.match(input.observabilitySettings.otlpMetricsUrl, { + onNone: () => ({}), + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), + }), + }, + httpBaseUrl: backendExposure.httpBaseUrl, + captureOutput: true, + }; + }, +); + +export const layer = Layer.effect( + DesktopBackendConfiguration, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const tokenRef = yield* Ref.make(Option.none()); + + return DesktopBackendConfiguration.of({ + resolve: Effect.gen(function* () { + const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef); + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolveBackendStartConfig({ + bootstrapToken, + observabilitySettings, + }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + ); + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), + }); + }), +); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts new file mode 100644 index 00000000000..6c5109c8714 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -0,0 +1,494 @@ +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} 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 Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +const decodeDesktopBackendBootstrap = Schema.decodeEffect( + Schema.fromJsonString(DesktopBackendBootstrap), +); + +const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +const configWithObservability: DesktopBackendBootstrapValue = { + ...baseConfig.bootstrap, + tailscaleServeEnabled: true, + 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 kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; +}): ChildProcessSpawner.ChildProcessHandle { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: options?.stdout ?? Stream.empty, + stderr: options?.stderr ?? Stream.empty, + all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), + exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: options?.kill ?? (() => Effect.void), + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +function responseForRequest( + request: HttpClientRequest.HttpClientRequest, + status: number, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb(request, new Response(null, { status })); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +const healthyHttpClientLayer = httpClientLayer((request) => + Effect.succeed(responseForRequest(request, 200)), +); + +function decodeBootstrap(raw: string) { + return decodeDesktopBackendBootstrap(raw); +} + +function makeManagerLayer(input: { + readonly spawnerLayer: Layer.Layer; + readonly httpClientLayer?: Layer.Layer; + readonly backendOutputLog?: Partial; + readonly desktopState?: DesktopState.DesktopStateShape; + readonly desktopWindow?: Partial; + readonly config?: DesktopBackendManager.DesktopBackendStartConfig; +}) { + return DesktopBackendManager.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + }), + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolve: Effect.succeed(input.config ?? baseConfig), + }), + input.spawnerLayer, + input.httpClientLayer ?? healthyHttpClientLayer, + input.desktopState + ? Layer.succeed(DesktopState.DesktopState, input.desktopState) + : DesktopState.layer, + Layer.succeed(DesktopObservability.DesktopBackendOutputLog, { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, + ...input.backendOutputLog, + } satisfies DesktopObservability.DesktopBackendOutputLogShape), + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + handleBackendReady: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + ...input.desktopWindow, + } satisfies DesktopWindow.DesktopWindowShape), + ), + ), + ); +} + +describe("DesktopBackendManager", () => { + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } + } + + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer, + desktopWindow: { + handleBackendReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), + }, + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand._tag !== "StandardCommand") { + throw new Error("Expected backend to spawn a standard command."); + } + + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 2_000, + ); + + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + }).pipe(Effect.provide(managerLayer)); + }), + ); + + it.effect("retries HTTP readiness before reporting the backend ready", () => + Effect.gen(function* () { + const requestUrls: Array = []; + const statuses = [503, 200]; + let readyCount = 0; + const firstRequest = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer((request) => + Effect.gen(function* () { + const status = statuses.shift(); + assert.isDefined(status); + requestUrls.push(request.url); + yield* Deferred.succeed(firstRequest, void 0); + return responseForRequest(request, status); + }), + ), + desktopWindow: { + handleBackendReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), + }, + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Deferred.await(firstRequest); + + assert.equal(readyCount, 0); + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); + + yield* TestClock.adjust(Duration.millis(100)); + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.deepEqual(requestUrls, [ + "http://127.0.0.1:3773/.well-known/t3/environment", + "http://127.0.0.1:3773/.well-known/t3/environment", + ]); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("starts the configured backend and closes the scoped process on stop", () => + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + const backendReady = yield* Ref.make(false); + const quitting = yield* Ref.make(false); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + 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); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + desktopState: { + backendReady, + quitting, + }, + desktopWindow: { + handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + assert.isTrue(Option.isNone(yield* manager.currentConfig)); + + yield* manager.start; + assert.equal(yield* Queue.take(startedPids), 123); + yield* Deferred.await(ready); + assert.isTrue(yield* Ref.get(backendReady)); + assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); + + const runningSnapshot = yield* manager.snapshot; + assert.equal(runningSnapshot.ready, true); + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); + + yield* manager.stop(); + assert.equal(startCount, 1); + assert.equal(closedCount, 1); + + const stoppedSnapshot = yield* manager.snapshot; + assert.isFalse(yield* Ref.get(backendReady)); + assert.equal(stoppedSnapshot.desiredRunning, false); + assert.equal(stoppedSnapshot.ready, false); + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); + }).pipe(Effect.provide(managerLayer)); + }), + ); + + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + + yield* TestClock.adjust(Duration.millis(499)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 2); + + yield* TestClock.adjust(Duration.millis(999)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 3); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("cancels a scheduled restart when start is requested manually", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const secondClosed = yield* Deferred.make(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + + if (startCount === 1) { + return makeProcess({ + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }); + } + + const scope = yield* Scope.Scope; + const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); + yield* Scope.addFinalizer(scope, close); + return makeProcess({ + exitCode: Deferred.await(secondClosed).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + + yield* manager.start; + assert.equal(yield* Queue.take(starts), 2); + + yield* manager.stop(); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.size(starts), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("does not restart after stop cancels a scheduled restart", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(starts), 1); + + let restartScheduled = false; + while (!restartScheduled) { + restartScheduled = (yield* manager.snapshot).restartScheduled; + if (!restartScheduled) { + yield* Effect.yieldNow; + } + } + + yield* manager.stop(); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.size(starts), 0); + assert.equal((yield* manager.snapshot).desiredRunning, false); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts new file mode 100644 index 00000000000..97931f42dbd --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -0,0 +1,596 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +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"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; + +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +const INITIAL_RESTART_DELAY = Duration.millis(500); +const MAX_RESTART_DELAY = Duration.seconds(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 BACKEND_READINESS_PATH = "/.well-known/t3/environment"; + +type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; + +type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; + +export type BackendProcessOutputStream = "stdout" | "stderr"; + +export interface DesktopBackendStartConfig { + readonly executablePath: string; + readonly entryPath: string; + readonly cwd: string; + readonly env: Record; + readonly bootstrap: DesktopBackendBootstrapValue; + readonly httpBaseUrl: URL; + readonly captureOutput: boolean; +} + +interface BackendProcessExit { + readonly code: Option.Option; + readonly reason: string; + readonly result: Result.Result; +} + +export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ + readonly url: URL; +}> { + override get message() { + return `Timed out waiting for backend readiness at ${this.url.href}.`; + } +} + +class BackendProcessBootstrapEncodeError extends Data.TaggedError( + "BackendProcessBootstrapEncodeError", +)<{ + readonly cause: Schema.SchemaError; +}> { + override get message() { + return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`; + } +} + +class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{ + readonly cause: PlatformError.PlatformError; +}> { + override get message() { + return `Failed to spawn desktop backend process: ${this.cause.message}`; + } +} + +type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; + +interface RunBackendProcessOptions extends DesktopBackendStartConfig { + readonly readinessTimeout?: Duration.Duration; + readonly onStarted?: (pid: number) => Effect.Effect; + readonly onReady?: () => Effect.Effect; + readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onOutput?: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect; +} + +export interface DesktopBackendSnapshot { + readonly desiredRunning: boolean; + readonly ready: boolean; + readonly activePid: Option.Option; + readonly restartAttempt: number; + readonly restartScheduled: boolean; +} + +export interface DesktopBackendManagerShape { + readonly start: Effect.Effect; + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; + readonly currentConfig: Effect.Effect>; + readonly snapshot: Effect.Effect; +} + +export class DesktopBackendManager extends Context.Service< + DesktopBackendManager, + DesktopBackendManagerShape +>()("t3/desktop/BackendManager") {} + +const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = + DesktopObservability.makeComponentLogger("desktop-backend-manager"); + +interface ActiveBackendRun { + readonly id: number; + readonly scope: Scope.Closeable; + readonly fiber: Option.Option>; + readonly pid: Option.Option; +} + +interface BackendManagerState { + readonly desiredRunning: boolean; + readonly ready: boolean; + readonly config: Option.Option; + readonly active: Option.Option; + readonly restartAttempt: number; + readonly restartFiber: Option.Option>; + readonly nextRunId: number; +} + +const initialState: BackendManagerState = { + desiredRunning: false, + ready: false, + config: Option.none(), + active: Option.none(), + restartAttempt: 0, + restartFiber: Option.none(), + nextRunId: 1, +}; + +const activePid = (active: Option.Option): Option.Option => + Option.flatMap(active, (run) => run.pid); + +const withActiveRun = + (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => + (state: BackendManagerState): BackendManagerState => ({ + ...state, + active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)), + }); + +const calculateRestartDelay = (attempt: number): Duration.Duration => + Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); + +const closeRun = ( + run: ActiveBackendRun, + options?: { readonly timeout?: Duration.Duration }, +): 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)); + + return ( + options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close + ).pipe(Effect.ignore); +}; + +const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + baseUrl: URL, + timeout: Duration.Duration, +): Effect.fn.Return { + const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.filterStatusOk, + HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), + HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), + ); + + yield* client.get(readinessUrl).pipe( + Effect.asVoid, + Effect.timeout(timeout), + Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), + ); +}); + +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( + streamName: BackendProcessOutputStream, + stream: Stream.Stream, + onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, +): Effect.Effect { + return stream.pipe( + Stream.runForEach((chunk) => onOutput(streamName, chunk)), + Effect.ignore, + ); +} + +const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + +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({ cause })), + ); + const onOutput = options.onOutput ?? (() => Effect.void); + const command = ChildProcess.make( + options.executablePath, + [options.entryPath, "--bootstrap-fd", "3"], + { + cwd: options.cwd, + env: options.env, + extendEnv: true, + // In Electron main, process.execPath points to the Electron binary. + // Run the child in Node mode so this backend process does not become a GUI app instance. + stdin: "ignore", + stdout: options.captureOutput ? "pipe" : "inherit", + stderr: options.captureOutput ? "pipe" : "inherit", + killSignal: "SIGTERM", + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, + additionalFds: { + fd3: { + type: "input", + stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), + }, + }, + }, + ); + + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); + + yield* options.onStarted?.(handle.pid) ?? Effect.void; + if (options.captureOutput) { + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + } + yield* waitForHttpReady( + options.httpBaseUrl, + options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + ).pipe( + Effect.tap(() => options.onReady?.() ?? Effect.void), + Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.forkScoped, + ); + + return describeProcessExit(yield* Effect.result(handle.exitCode)); +}); + +const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { + const parentScope = yield* Scope.Scope; + const fileSystem = yield* FileSystem.FileSystem; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; + const desktopState = yield* DesktopState.DesktopState; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const state = yield* Ref.make(initialState); + const mutex = yield* Semaphore.make(1); + + const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => + Ref.update(state, withActiveRun(runId, f)); + + const snapshot = Ref.get(state).pipe( + Effect.map( + (current): DesktopBackendSnapshot => ({ + desiredRunning: current.desiredRunning, + ready: current.ready, + activePid: activePid(current.active), + restartAttempt: current.restartAttempt, + restartScheduled: Option.isSome(current.restartFiber), + }), + ), + ); + const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); + + const cancelRestart = Effect.gen(function* () { + const restartFiber = yield* Ref.modify(state, (current) => [ + current.restartFiber, + { + ...current, + restartFiber: Option.none(), + }, + ]); + + yield* Option.match(restartFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); + }); + + const start: Effect.Effect = Effect.suspend(() => + mutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (Option.isSome(current.active)) { + return; + } + + yield* Ref.set(desktopState.backendReady, false); + const config = yield* configuration.resolve; + const entryExists = yield* fileSystem + .exists(config.entryPath) + .pipe(Effect.orElseSucceed(() => false)); + + yield* cancelRestart; + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: true, + ready: false, + config: Option.some(config), + })); + + if (!entryExists) { + yield* scheduleRestart(`missing server entry at ${config.entryPath}`); + return; + } + + const runScope = yield* Scope.make("sequential"); + const runId = yield* Ref.modify(state, (latest) => [ + latest.nextRunId, + { + ...latest, + active: Option.some({ + id: latest.nextRunId, + scope: runScope, + fiber: Option.none(), + pid: Option.none(), + } satisfies ActiveBackendRun), + nextRunId: latest.nextRunId + 1, + }, + ]); + + const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( + reason: string, + ) { + 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) { + return [ + { + isCurrentRun: false, + nextState: latest, + pid: Option.none(), + }, + latest, + ] as const; + } + + const next = { + ...latest, + active: Option.none(), + ready: false, + }; + return [ + { + isCurrentRun: true, + nextState: next, + pid: currentRun.pid, + }, + next, + ] as const; + }, + ); + + if (isCurrentRun) { + if (Option.isSome(pid)) { + yield* backendOutputLog.writeSessionBoundary({ + phase: "END", + details: `pid=${pid.value} ${reason}`, + }); + } + yield* Ref.set(desktopState.backendReady, false); + } + + if (isCurrentRun && nextState.desiredRunning) { + yield* scheduleRestart(reason); + } + }), + ); + }); + + const program = runBackendProcess({ + ...config, + onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { + yield* updateActiveRun(runId, (run) => ({ + ...run, + pid: Option.some(pid), + })); + yield* backendOutputLog.writeSessionBoundary({ + phase: "START", + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, + }); + }), + onReady: Effect.fn("desktop.backendManager.onReady")(function* () { + const isCurrentRun = yield* Ref.modify(state, (latest) => { + const activeRun = Option.getOrUndefined(latest.active); + if (activeRun?.id !== runId) { + return [false, latest] as const; + } + + return [ + true, + { + ...latest, + restartAttempt: 0, + ready: true, + }, + ] as const; + }); + if (!isCurrentRun) { + return; + } + + yield* Ref.set(desktopState.backendReady, true); + yield* desktopWindow.handleBackendReady.pipe( + Effect.catch((error) => + logBackendManagerError("failed to open main window after backend readiness", { + message: error.message, + }), + ), + ); + }), + onReadinessFailure: (error) => + logBackendManagerWarning("backend readiness check failed during bootstrap", { + error: error.message, + }), + onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + Scope.provide(runScope), + Effect.matchEffect({ + onFailure: (error) => finalizeRun(error.message), + onSuccess: (exit) => finalizeRun(exit.reason), + }), + Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), + ); + + const fiber = yield* Effect.forkIn(program, parentScope); + yield* updateActiveRun(runId, (run) => ({ + ...run, + fiber: Option.some(fiber), + })); + }), + ), + ).pipe(Effect.withSpan("desktop.backendManager.start")); + + const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* ( + reason: string, + ) { + const scheduled = yield* Ref.modify(state, (latest) => { + if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { + return [Option.none(), latest] as const; + } + + const delay = calculateRestartDelay(latest.restartAttempt); + return [ + Option.some(delay), + { + ...latest, + restartAttempt: latest.restartAttempt + 1, + }, + ] as const; + }); + + yield* Option.match(scheduled, { + onNone: () => Effect.void, + onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { + yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { + reason, + delayMs: Duration.toMillis(delay), + }); + const restartFiber = yield* Effect.forkIn( + Effect.sleep(delay).pipe( + Effect.andThen( + Ref.modify(state, (latest) => { + const shouldRestart = latest.desiredRunning; + return [ + shouldRestart, + { + ...latest, + restartFiber: Option.none(), + }, + ] as const; + }), + ), + Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), + Effect.catchCause((cause) => + logBackendManagerError("desktop backend restart fiber failed", { + cause: Cause.pretty(cause), + }), + ), + ), + parentScope, + ); + yield* Ref.update(state, (latest) => + Option.isNone(latest.restartFiber) + ? { + ...latest, + restartFiber: Option.some(restartFiber), + } + : latest, + ); + }), + }); + }); + + const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { + readonly timeout?: Duration.Duration; + }) { + const { active, restartFiber } = 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>(), + }, + ]); + yield* Ref.set(desktopState.backendReady, false); + return result; + }), + ); + + 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), + }); + }); + + yield* Effect.addFinalizer(() => stop()); + + return DesktopBackendManager.of({ + start, + stop, + currentConfig, + snapshot, + }); +}); + +export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts new file mode 100644 index 00000000000..0f3e9eaeb45 --- /dev/null +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -0,0 +1,365 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + DesktopEnvironment, + layer as makeDesktopEnvironmentLayer, +} from "../app/DesktopEnvironment.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; + +const encoder = new TextEncoder(); + +const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopNetworkInterfaces = { + en0: [ + { + address: "192.168.1.20", + family: "IPv4", + internal: false, + }, + ], +}; + +const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { + tailscale0: [ + { + address: "100.90.1.2", + family: "IPv4", + internal: false, + }, + ], +}; + +function mockSpawnerLayer(statusJson = "{}") { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(statusJson)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); +} + +function makeEnvironmentLayer(baseDir: string, env: Record = {}) { + return makeDesktopEnvironmentLayer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })), + ), + ); +} + +function makeLayer(input: { + readonly baseDir: string; + readonly networkInterfaces?: DesktopNetworkInterfaces; + readonly env?: Record; +}) { + const env = { T3CODE_HOME: input.baseDir, ...input.env }; + const environmentLayer = makeEnvironmentLayer(input.baseDir, env); + const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { + read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), + }); + + return DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(NodeFileSystem.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(mockSpawnerLayer()), + Layer.provideMerge(networkLayer), + Layer.provideMerge(DesktopConfig.layerTest(env)), + Layer.provideMerge(environmentLayer), + ); +} + +const withHarness = ( + networkInterfaces: DesktopNetworkInterfaces, + effect: Effect.Effect< + A, + E, + | R + | DesktopEnvironment + | FileSystem.FileSystem + | DesktopServerExposure.DesktopServerExposure + | DesktopAppSettings.DesktopAppSettings + >, + env: Record = {}, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-server-exposure-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopServerExposure", () => { + it.effect("falls back to local-only without losing the requested network preference", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "local-only"); + assert.equal(state.endpointUrl, null); + assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "127.0.0.1"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + }), + ), + ); + + it.effect("returns a typed error when network access is explicitly unavailable", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); + assert.equal(error.port, 4173); + }), + ), + ); + + it.effect("persists network-accessible mode and updates backend binding state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.requiresRelaunch, true); + assert.deepEqual(change.state, { + mode: "network-accessible", + endpointUrl: "http://192.168.1.20:4173", + advertisedHost: "192.168.1.20", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "0.0.0.0"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + + const persisted = yield* settings.get; + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const changed = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(changed.requiresRelaunch, true); + assert.equal(changed.state.tailscaleServeEnabled, true); + assert.equal(changed.state.tailscaleServePort, 8443); + + const unchanged = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(unchanged.requiresRelaunch, false); + + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, true); + assert.equal(persisted.tailscaleServePort, 8443); + }), + ), + ); + + it.effect("resolves advertised endpoints from the scoped runtime state", () => + withHarness( + { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"], + ); + }), + ), + ); + + it.effect("uses ConfigProvider desktop exposure overrides", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + const change = yield* serverExposure.setMode("network-accessible"); + + assert.equal(change.state.advertisedHost, "10.0.0.7"); + assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ); + }), + { + T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", + }, + ), + ); + + it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 3773 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual(endpoints, [ + { + id: "desktop-loopback:3773", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }, + { + id: "desktop-lan:http://192.168.1.20:3773", + label: "Local network", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://192.168.1.20:3773/", + wsBaseUrl: "ws://192.168.1.20:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }, + { + id: "manual:https://desktop.example.ts.net", + label: "Custom HTTPS", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "https://desktop.example.ts.net/", + wsBaseUrl: "wss://desktop.example.ts.net/", + reachability: "public", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }, + { + id: "manual:http://desktop.example.test:3773", + label: "Custom endpoint", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "http://desktop.example.test:3773/", + wsBaseUrl: "ws://desktop.example.test:3773/", + reachability: "public", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured endpoint for this desktop backend.", + }, + ]); + }), + { + T3CODE_DESKTOP_HTTPS_ENDPOINTS: + "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", + }, + ), + ); +}); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts new file mode 100644 index 00000000000..bd0939a7775 --- /dev/null +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -0,0 +1,548 @@ +import * as NodeOS from "node:os"; + +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/shared/advertisedEndpoint"; +import type { + AdvertisedEndpoint, + AdvertisedEndpointProvider, + DesktopServerExposureMode, + DesktopServerExposureState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; + +export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; + +export interface DesktopNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; + readonly netmask?: string; + readonly mac?: string; + readonly cidr?: string | null; + readonly scopeid?: number; +} + +export type DesktopNetworkInterfaces = Readonly< + Record +>; + +interface ResolvedDesktopServerExposure { + readonly mode: DesktopServerExposureMode; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly endpointUrl: string | null; + readonly advertisedHost: string | null; +} + +interface DesktopAdvertisedEndpointInput { + readonly port: number; + readonly exposure: ResolvedDesktopServerExposure; + readonly customHttpsEndpointUrls?: readonly string[]; +} + +const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +}; + +const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, +}; + +const normalizeOptionalHost = (value: string | undefined): string | undefined => { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +}; + +const isUsableLanIpv4Address = (address: string): boolean => + !address.startsWith("127.") && !address.startsWith("169.254."); + +const isHttpsEndpointUrl = (value: string): boolean => { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +}; + +const resolveLanAdvertisedHost = ( + networkInterfaces: DesktopNetworkInterfaces, + explicitHost: string | undefined, +): string | null => { + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); + if (normalizedExplicitHost) { + return normalizedExplicitHost; + } + + for (const interfaceAddresses of Object.values(networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isUsableLanIpv4Address(address.address)) continue; + return address.address; + } + } + + return null; +}; + +const resolveDesktopServerExposure = (input: { + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly advertisedHostOverride?: string; +}): ResolvedDesktopServerExposure => { + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + + if (input.mode === "local-only") { + return { + mode: input.mode, + bindHost: DESKTOP_LOOPBACK_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: null, + advertisedHost: null, + }; + } + + const advertisedHost = resolveLanAdvertisedHost( + input.networkInterfaces, + input.advertisedHostOverride, + ); + + return { + mode: input.mode, + bindHost: DESKTOP_LAN_BIND_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, + advertisedHost, + }; +}; + +const createDesktopEndpoint = ( + input: Omit, +): AdvertisedEndpoint => + createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_CORE_ENDPOINT_PROVIDER, + source: "desktop-core", + }); + +const createManualEndpoint = ( + input: Omit, +): AdvertisedEndpoint => + createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, + source: "user", + }); + +const resolveDesktopCoreAdvertisedEndpoints = ( + input: DesktopAdvertisedEndpointInput, +): readonly AdvertisedEndpoint[] => { + const endpoints: AdvertisedEndpoint[] = [ + createDesktopEndpoint({ + id: `desktop-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: input.exposure.localHttpUrl, + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this desktop app.", + }), + ]; + + if (input.exposure.endpointUrl) { + endpoints.push( + createDesktopEndpoint({ + id: `desktop-lan:${input.exposure.endpointUrl}`, + label: "Local network", + httpBaseUrl: input.exposure.endpointUrl, + reachability: "lan", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }), + ); + } + + for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { + try { + const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); + endpoints.push( + createManualEndpoint({ + id: `manual:${customEndpointUrl}`, + label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", + httpBaseUrl: customEndpointUrl, + reachability: "public", + ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), + status: "unknown", + description: isHttpsEndpoint + ? "User-configured HTTPS endpoint for this desktop backend." + : "User-configured endpoint for this desktop backend.", + }), + ); + } catch { + // Ignore malformed user-configured endpoints without dropping valid endpoints. + } + } + + return endpoints; +}; + +type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; + +export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( + "DesktopServerExposureNoNetworkAddressError", +)<{ + readonly port: number; +}> { + override get message() { + return `No reachable network address is available for desktop network access on port ${this.port}.`; + } +} + +export class DesktopServerExposurePersistenceError extends Data.TaggedError( + "DesktopServerExposurePersistenceError", +)<{ + readonly operation: DesktopServerExposurePersistenceOperation; + readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; +}> { + override get message() { + return `Failed to persist desktop ${this.operation} settings.`; + } +} + +export type DesktopServerExposureSetModeError = + | DesktopServerExposureNoNetworkAddressError + | DesktopServerExposurePersistenceError; + +export type DesktopServerExposureError = DesktopServerExposureSetModeError; + +export interface DesktopServerExposureBackendConfig { + readonly port: number; + readonly bindHost: string; + readonly httpBaseUrl: URL; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; +} + +export interface DesktopServerExposureChange { + readonly state: DesktopServerExposureState; + readonly requiresRelaunch: boolean; +} + +export interface DesktopServerExposureShape { + readonly getState: Effect.Effect; + readonly backendConfig: Effect.Effect; + readonly configureFromSettings: (input: { + readonly port: number; + }) => Effect.Effect; + readonly setMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServeEnabled: (input: { + readonly enabled: boolean; + readonly port?: number; + }) => Effect.Effect; + readonly getAdvertisedEndpoints: Effect.Effect; +} + +export class DesktopServerExposure extends Context.Service< + DesktopServerExposure, + DesktopServerExposureShape +>()("t3/desktop/ServerExposure") {} + +export interface DesktopNetworkInterfacesServiceShape { + readonly read: Effect.Effect; +} + +export class DesktopNetworkInterfacesService extends Context.Service< + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesServiceShape +>()("t3/desktop/ServerExposure/NetworkInterfaces") {} + +interface RuntimeState { + readonly requestedMode: DesktopServerExposureMode; + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly httpBaseUrl: URL; + readonly endpointUrl: Option.Option; + readonly advertisedHost: Option.Option; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; +} + +interface ResolvedRuntimeState { + readonly state: RuntimeState; + readonly unavailable: boolean; +} + +const initialRuntimeState = (): RuntimeState => + runtimeStateFromResolvedExposure({ + requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + settings: DEFAULT_DESKTOP_SETTINGS, + exposure: resolveDesktopServerExposure({ + mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + port: 0, + networkInterfaces: {}, + }), + port: 0, + }); + +const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ + mode: state.mode, + endpointUrl: Option.getOrNull(state.endpointUrl), + advertisedHost: Option.getOrNull(state.advertisedHost), + tailscaleServeEnabled: state.tailscaleServeEnabled, + tailscaleServePort: state.tailscaleServePort, +}); + +const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({ + port: state.port, + bindHost: state.bindHost, + httpBaseUrl: state.httpBaseUrl, + tailscaleServeEnabled: state.tailscaleServeEnabled, + tailscaleServePort: state.tailscaleServePort, +}); + +const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({ + mode: state.mode, + bindHost: state.bindHost, + localHttpUrl: state.localHttpUrl, + localWsUrl: state.localWsUrl, + endpointUrl: Option.getOrNull(state.endpointUrl), + advertisedHost: Option.getOrNull(state.advertisedHost), +}); + +function runtimeStateFromResolvedExposure(input: { + readonly requestedMode: DesktopServerExposureMode; + readonly settings: DesktopSettings; + readonly exposure: ResolvedDesktopServerExposure; + readonly port: number; +}): RuntimeState { + return { + requestedMode: input.requestedMode, + mode: input.exposure.mode, + port: input.port, + bindHost: input.exposure.bindHost, + localHttpUrl: input.exposure.localHttpUrl, + localWsUrl: input.exposure.localWsUrl, + httpBaseUrl: new URL(input.exposure.localHttpUrl), + endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl), + advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), + tailscaleServeEnabled: input.settings.tailscaleServeEnabled, + tailscaleServePort: input.settings.tailscaleServePort, + }; +} + +function resolveRuntimeState(input: { + readonly requestedMode: DesktopServerExposureMode; + readonly settings: DesktopSettings; + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly advertisedHostOverride: Option.Option; +}): ResolvedRuntimeState { + const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); + const requestedExposure = resolveDesktopServerExposure({ + mode: input.requestedMode, + port: input.port, + networkInterfaces: input.networkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + const unavailable = + input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + const exposure = unavailable + ? resolveDesktopServerExposure({ + mode: "local-only", + port: input.port, + networkInterfaces: input.networkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }) + : requestedExposure; + + return { + state: runtimeStateFromResolvedExposure({ + requestedMode: input.requestedMode, + settings: input.settings, + exposure, + port: input.port, + }), + unavailable, + }; +} + +const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => + previous.port !== next.port || + previous.bindHost !== next.bindHost || + previous.localHttpUrl !== next.localHttpUrl; + +const make = Effect.gen(function* () { + const config = yield* DesktopConfig.DesktopConfig; + const networkInterfaces = yield* DesktopNetworkInterfacesService; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; + const stateRef = yield* Ref.make(initialRuntimeState()); + + const readNetworkInterfaces = networkInterfaces.read; + + const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); + const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); + + const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( + function* ({ port }: { readonly port: number }) { + yield* Effect.annotateCurrentSpan({ port }); + const settings = yield* desktopSettings.get; + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: settings.serverExposureMode, + settings, + port, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }); + yield* Ref.set(stateRef, resolved.state); + return toContractState(resolved.state); + }, + ); + + const setMode = Effect.fn("desktop.serverExposure.setMode")(function* ( + mode: DesktopServerExposureMode, + ) { + yield* Effect.annotateCurrentSpan({ mode }); + const previous = yield* Ref.get(stateRef); + const currentSettings = yield* desktopSettings.get; + const nextSettings = { + ...currentSettings, + serverExposureMode: mode, + }; + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: mode, + settings: nextSettings, + port: previous.port, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }); + + if (resolved.unavailable) { + return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); + } + + const change = yield* desktopSettings.setServerExposureMode(mode).pipe( + Effect.mapError( + (cause) => + new DesktopServerExposurePersistenceError({ + operation: "server-exposure-mode", + cause, + }), + ), + ); + + yield* Ref.set(stateRef, resolved.state); + return { + state: toContractState(resolved.state), + requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state), + }; + }); + + const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")( + function* (input: { readonly enabled: boolean; readonly port?: number }) { + yield* Effect.annotateCurrentSpan({ + enabled: input.enabled, + ...(input.port === undefined ? {} : { port: input.port }), + }); + const result = yield* desktopSettings + .setTailscaleServe({ + enabled: input.enabled, + port: Option.fromNullishOr(input.port), + }) + .pipe( + Effect.mapError( + (cause) => + new DesktopServerExposurePersistenceError({ + operation: "tailscale-serve", + cause, + }), + ), + ); + + const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ + ...current, + tailscaleServeEnabled: result.settings.tailscaleServeEnabled, + tailscaleServePort: result.settings.tailscaleServePort, + })); + + return { + state: toContractState(nextState), + requiresRelaunch: result.changed, + }; + }, + ); + + const getAdvertisedEndpoints = Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ + port: state.port, + exposure: toResolvedExposure(state), + customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, + }); + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: state.port, + serveEnabled: state.tailscaleServeEnabled, + servePort: state.tailscaleServePort, + networkInterfaces: currentNetworkInterfaces, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return [...coreEndpoints, ...tailscaleEndpoints]; + }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); + + return DesktopServerExposure.of({ + getState, + backendConfig, + configureFromSettings, + setMode, + setTailscaleServeEnabled, + getAdvertisedEndpoints, + }); +}); + +export const layer = Layer.effect(DesktopServerExposure, make); + +export const networkInterfacesLayer = Layer.succeed( + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesService.of({ + read: Effect.sync(() => NodeOS.networkInterfaces()), + }), +); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts new file mode 100644 index 00000000000..612ef3bd73f --- /dev/null +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -0,0 +1,142 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + resolveTailscaleAdvertisedEndpoints, +} from "./tailscaleEndpointProvider.ts"; + +const unusedTailscaleExternalServicesLayer = Layer.mergeAll( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), + ), +); + +describe("tailscale endpoint provider", () => { + it("detects Tailnet IPv4 addresses", () => { + assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); + assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); + assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); + assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); + }); + + it.effect("parses MagicDNS names from tailscale status", () => + Effect.gen(function* () { + const dnsName = yield* parseTailscaleMagicDnsName( + `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + ); + assert.equal(dnsName, "desktop.tail.ts.net"); + assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); + const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); + assert.isTrue(malformed._tag === "Failure"); + }), + ); + + it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + tailscale0: [ + { + address: "100.100.100.100", + family: "IPv4", + internal: false, + netmask: "255.192.0.0", + cidr: "100.100.100.100/10", + mac: "00:00:00:00:00:00", + }, + ], + }, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-ip:http://100.100.100.100:3773", + label: "Tailscale IP", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "http://100.100.100.100:3773/", + wsBaseUrl: "ws://100.100.100.100:3773/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }, + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "requires-configuration", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "unavailable", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }, + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); + + it.effect( + "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", + () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: {}, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + serveEnabled: true, + probe: () => Effect.succeed(true), + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "HTTPS endpoint served by Tailscale Serve.", + }, + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); +}); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts new file mode 100644 index 00000000000..bd46e9f03f5 --- /dev/null +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -0,0 +1,138 @@ +import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { + buildTailscaleHttpsBaseUrl, + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; + +export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; + +const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, +}; + +function resolveTailscaleIpAdvertisedEndpoints(input: { + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; +}): readonly AdvertisedEndpoint[] { + const seen = new Set(); + const endpoints: AdvertisedEndpoint[] = []; + + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isTailscaleIpv4Address(address.address)) continue; + if (seen.has(address.address)) continue; + seen.add(address.address); + + endpoints.push( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-ip:http://${address.address}:${input.port}`, + label: "Tailscale IP", + httpBaseUrl: `http://${address.address}:${input.port}`, + reachability: "private-network", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }), + ); + } + } + + return endpoints; +} + +const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( + "resolveTailscaleMagicDnsAdvertisedEndpoint", +)(function* (input: { + readonly dnsName: string | null; + readonly serveEnabled: boolean; + readonly servePort?: number; + readonly probe?: (baseUrl: string) => Effect.Effect; +}): Effect.fn.Return, never, HttpClient.HttpClient> { + if (!input.dnsName) { + return Option.none(); + } + + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName: input.dnsName, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + }); + const probe = + input.probe?.(httpBaseUrl) ?? + probeTailscaleHttpsEndpoint({ + baseUrl: httpBaseUrl, + }); + const isReachable = input.serveEnabled ? yield* probe : false; + + return Option.some( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + reachability: "private-network", + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", + status: isReachable ? "available" : "unavailable", + description: isReachable + ? "HTTPS endpoint served by Tailscale Serve." + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }), + ); +}); + +export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( + function* (input: { + readonly port: number; + readonly serveEnabled?: boolean; + readonly servePort?: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly statusJson?: string | null; + readonly probe?: (baseUrl: string) => Effect.Effect; + }): Effect.fn.Return< + readonly AdvertisedEndpoint[], + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient + > { + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const dnsName = + input.statusJson === undefined + ? yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.catch(() => Effect.succeed(null)), + ) + : input.statusJson + ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + : null; + const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName, + serveEnabled: input.serveEnabled === true, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.probe === undefined ? {} : { probe: input.probe }), + }); + + return Option.match(magicDnsEndpoint, { + onNone: () => ipEndpoints, + onSome: (endpoint) => [...ipEndpoints, endpoint], + }); + }, +); diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts deleted file mode 100644 index 8f586deb702..00000000000 --- a/apps/desktop/src/backendPort.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { resolveDesktopBackendPort } from "./backendPort"; - -describe("resolveDesktopBackendPort", () => { - it("returns the starting port when it is available", async () => { - const canListenOnHost = vi.fn(async (port: number) => port === 3773); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3773); - - expect(canListenOnHost).toHaveBeenCalledTimes(1); - expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); - }); - - it("increments sequentially until it finds an available port", async () => { - const canListenOnHost = vi.fn(async (port: number) => port === 3775); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3775); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3774, "127.0.0.1"], - [3775, "127.0.0.1"], - ]); - }); - - it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { - const canListenOnHost = vi.fn(async (port: number, host: string) => { - if (port === 3773 && host === "127.0.0.1") return true; - if (port === 3773 && host === "0.0.0.0") return false; - return port === 3774; - }); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - requiredHosts: ["0.0.0.0"], - startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3774); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3774, "127.0.0.1"], - [3774, "0.0.0.0"], - ]); - }); - - it("checks overlapping hosts sequentially to avoid self-interference", async () => { - let inFlightCount = 0; - const canListenOnHost = vi.fn(async (_port: number, _host: string) => { - inFlightCount += 1; - const overlapped = inFlightCount > 1; - await Promise.resolve(); - inFlightCount -= 1; - return !overlapped; - }); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - requiredHosts: ["0.0.0.0", "::"], - startPort: 3773, - maxPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3773); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3773, "::"], - ]); - }); - - it("fails when the scan range is exhausted", async () => { - const canListenOnHost = vi.fn(async () => false); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - startPort: 65534, - maxPort: 65535, - canListenOnHost, - }), - ).rejects.toThrow( - "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", - ); - - expect(canListenOnHost.mock.calls).toEqual([ - [65534, "127.0.0.1"], - [65535, "127.0.0.1"], - ]); - }); -}); diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts deleted file mode 100644 index 1ce90a257fa..00000000000 --- a/apps/desktop/src/backendPort.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as Effect from "effect/Effect"; -import { NetService } from "@t3tools/shared/Net"; - -export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; -const MAX_TCP_PORT = 65_535; - -export interface ResolveDesktopBackendPortOptions { - readonly host: string; - readonly startPort?: number; - readonly maxPort?: number; - readonly requiredHosts?: ReadonlyArray; - readonly canListenOnHost?: (port: number, host: string) => Promise; -} - -const defaultCanListenOnHost = async (port: number, host: string): Promise => - Effect.service(NetService).pipe( - Effect.flatMap((net) => net.canListenOnHost(port, host)), - Effect.provide(NetService.layer), - Effect.runPromise, - ); - -const isValidPort = (port: number): boolean => - Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; - -const normalizeHosts = ( - host: string, - requiredHosts: ReadonlyArray, -): ReadonlyArray => - Array.from( - new Set( - [host, ...requiredHosts] - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0), - ), - ); - -async function canListenOnAllHosts( - port: number, - hosts: ReadonlyArray, - canListenOnHost: (port: number, host: string) => Promise, -): Promise { - for (const candidateHost of hosts) { - if (!(await canListenOnHost(port, candidateHost))) { - return false; - } - } - - return true; -} - -export async function resolveDesktopBackendPort({ - host, - startPort = DEFAULT_DESKTOP_BACKEND_PORT, - maxPort = MAX_TCP_PORT, - requiredHosts = [], - canListenOnHost = defaultCanListenOnHost, -}: ResolveDesktopBackendPortOptions): Promise { - if (!isValidPort(startPort)) { - throw new Error(`Invalid desktop backend start port: ${startPort}`); - } - - if (!isValidPort(maxPort)) { - throw new Error(`Invalid desktop backend max port: ${maxPort}`); - } - - if (maxPort < startPort) { - throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); - } - - const hostsToCheck = normalizeHosts(host, requiredHosts); - - // Keep desktop startup predictable across app restarts by probing upward from - // the same preferred port instead of picking a fresh ephemeral port. - for (let port = startPort; port <= maxPort; port += 1) { - if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { - return port; - } - } - - throw new Error( - `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, - ); -} diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts deleted file mode 100644 index fd6180b5dac..00000000000 --- a/apps/desktop/src/backendReadiness.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - BackendReadinessAbortedError, - isBackendReadinessAborted, - waitForHttpReady, -} from "./backendReadiness"; - -describe("waitForHttpReady", () => { - it("returns once the backend reports a successful session endpoint", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 503 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - }); - - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it("retries after a readiness request stalls past the per-request timeout", async () => { - const fetchImpl = vi - .fn() - .mockImplementationOnce( - (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener( - "abort", - () => { - reject(new Error("request timed out")); - }, - { once: true }, - ); - }) as ReturnType, - ) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 100, - intervalMs: 0, - requestTimeoutMs: 1, - }); - - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it("aborts an in-flight readiness wait", async () => { - const controller = new AbortController(); - const fetchImpl = vi.fn().mockImplementation( - () => - new Promise((_resolve, reject) => { - controller.signal.addEventListener( - "abort", - () => { - reject(new BackendReadinessAbortedError()); - }, - { once: true }, - ); - }) as ReturnType, - ); - - const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - signal: controller.signal, - }); - - controller.abort(); - - await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); - }); - - it("recognizes aborted readiness errors", () => { - expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); - expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); - }); -}); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts deleted file mode 100644 index cd5a3c023ef..00000000000 --- a/apps/desktop/src/backendReadiness.ts +++ /dev/null @@ -1,103 +0,0 @@ -export interface WaitForHttpReadyOptions { - readonly timeoutMs?: number; - readonly intervalMs?: number; - readonly requestTimeoutMs?: number; - readonly fetchImpl?: typeof fetch; - readonly signal?: AbortSignal; -} - -const DEFAULT_TIMEOUT_MS = 10_000; -const DEFAULT_INTERVAL_MS = 100; -const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; - -export class BackendReadinessAbortedError extends Error { - constructor() { - super("Backend readiness wait was aborted."); - this.name = "BackendReadinessAbortedError"; - } -} - -function delay(ms: number, signal: AbortSignal | undefined): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - - const onAbort = () => { - cleanup(); - reject(new BackendReadinessAbortedError()); - }; - - const cleanup = () => { - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - }; - - if (signal?.aborted) { - cleanup(); - reject(new BackendReadinessAbortedError()); - return; - } - - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - -export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { - return error instanceof BackendReadinessAbortedError; -} - -export async function waitForHttpReady( - baseUrl: string, - options?: WaitForHttpReadyOptions, -): Promise { - const fetchImpl = options?.fetchImpl ?? fetch; - const signal = options?.signal; - const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; - const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - const deadline = Date.now() + timeoutMs; - - for (;;) { - if (signal?.aborted) { - throw new BackendReadinessAbortedError(); - } - - const requestController = new AbortController(); - const requestTimeout = setTimeout(() => { - requestController.abort(); - }, requestTimeoutMs); - const abortRequest = () => { - requestController.abort(); - }; - signal?.addEventListener("abort", abortRequest, { once: true }); - - try { - const response = await fetchImpl(`${baseUrl}/api/auth/session`, { - redirect: "manual", - signal: requestController.signal, - }); - if (response.ok) { - return; - } - } catch (error) { - if (isBackendReadinessAborted(error)) { - throw error; - } - if (signal?.aborted) { - throw new BackendReadinessAbortedError(); - } - // Retry until the backend becomes reachable or the deadline expires. - } finally { - clearTimeout(requestTimeout); - signal?.removeEventListener("abort", abortRequest); - } - - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); - } - - await delay(intervalMs, signal); - } -} diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts deleted file mode 100644 index df2178c0b0d..00000000000 --- a/apps/desktop/src/clientPersistence.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { - EnvironmentId, - type ClientSettings, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vitest"; - -import { - readClientSettings, - readSavedEnvironmentRegistry, - readSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - writeClientSettings, - writeSavedEnvironmentRegistry, - writeSavedEnvironmentSecret, - type DesktopSecretStorage, -} from "./clientPersistence"; - -const tempDirectories: string[] = []; - -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }); - } -}); - -function makeTempPath(fileName: string): string { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-")); - tempDirectories.push(directory); - return path.join(directory, fileName); -} - -function makeSecretStorage(available: boolean): DesktopSecretStorage { - return { - isEncryptionAvailable: () => available, - encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), - decryptString: (value) => { - const decoded = value.toString("utf8"); - if (!decoded.startsWith("enc:")) { - throw new Error("invalid secret"); - } - return decoded.slice("enc:".length); - }, - }; -} - -const clientSettings: ClientSettings = { - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", -}; - -const savedRegistryRecord: PersistedSavedEnvironmentRecord = { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: "2026-04-09T01:00:00.000Z", -}; - -describe("clientPersistence", () => { - it("persists and reloads client settings", () => { - const settingsPath = makeTempPath("client-settings.json"); - - writeClientSettings(settingsPath, clientSettings); - - expect(readClientSettings(settingsPath)).toEqual(clientSettings); - }); - - it("persists and reloads saved environment metadata", () => { - const registryPath = makeTempPath("saved-environments.json"); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); - }); - - it("persists encrypted saved environment secrets when encryption is available", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - expect( - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }), - ).toBe(true); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }), - ).toBe("bearer-token"); - - expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ - records: [ - { - ...savedRegistryRecord, - encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), - }, - ], - }); - }); - - it("preserves existing secrets when encryption is unavailable", () => { - const registryPath = makeTempPath("saved-environments.json"); - const availableSecretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage: availableSecretStorage, - }); - - expect( - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "next-token", - secretStorage: makeSecretStorage(false), - }), - ).toBe(false); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage: availableSecretStorage, - }), - ).toBe("bearer-token"); - }); - - it("removes saved environment secrets", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - - removeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - }); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }), - ).toBeNull(); - }); - - it("treats malformed secrets documents as empty", () => { - const registryPath = makeTempPath("saved-environments.json"); - fs.writeFileSync(registryPath, "{}\n", "utf8"); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage: makeSecretStorage(true), - }), - ).toBeNull(); - - expect(() => - removeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - }), - ).not.toThrow(); - }); - - it("returns false when writing a secret without metadata", () => { - const registryPath = makeTempPath("saved-environments.json"); - - expect( - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage: makeSecretStorage(true), - }), - ).toBe(false); - }); - - it("preserves encrypted secrets when metadata is rewritten", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }), - ).toBe("bearer-token"); - }); -}); diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts deleted file mode 100644 index 183de1a971b..00000000000 --- a/apps/desktop/src/clientPersistence.ts +++ /dev/null @@ -1,216 +0,0 @@ -import * as FS from "node:fs"; -import * as Path from "node:path"; - -import type { ClientSettings, PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; -import { Predicate } from "effect"; - -interface ClientSettingsDocument { - readonly settings: ClientSettings; -} - -interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { - readonly encryptedBearerToken?: string; -} - -interface SavedEnvironmentRegistryDocument { - readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; -} - -export interface DesktopSecretStorage { - readonly isEncryptionAvailable: () => boolean; - readonly encryptString: (value: string) => Buffer; - readonly decryptString: (value: Buffer) => string; -} - -function readJsonFile(filePath: string): T | null { - try { - if (!FS.existsSync(filePath)) { - return null; - } - return JSON.parse(FS.readFileSync(filePath, "utf8")) as T; - } catch { - return null; - } -} - -function writeJsonFile(filePath: string, value: unknown): void { - const directory = Path.dirname(filePath); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - FS.mkdirSync(directory, { recursive: true }); - FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - FS.renameSync(tempPath, filePath); -} - -function isPersistedSavedEnvironmentStorageRecord( - value: unknown, -): value is PersistedSavedEnvironmentStorageRecord { - return ( - Predicate.isObject(value) && - typeof value.environmentId === "string" && - typeof value.label === "string" && - typeof value.httpBaseUrl === "string" && - typeof value.wsBaseUrl === "string" && - typeof value.createdAt === "string" && - (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && - (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") - ); -} - -function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument { - const parsed = readJsonFile(filePath); - if (!Predicate.isObject(parsed)) { - return { records: [] }; - } - - return { - records: Array.isArray(parsed.records) - ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord) - : [], - }; -} - -function toPersistedSavedEnvironmentRecord( - record: PersistedSavedEnvironmentStorageRecord, -): PersistedSavedEnvironmentRecord { - return { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - }; -} - -export function readClientSettings(settingsPath: string): ClientSettings | null { - return readJsonFile(settingsPath)?.settings ?? null; -} - -export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { - writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); -} - -export function readSavedEnvironmentRegistry( - registryPath: string, -): readonly PersistedSavedEnvironmentRecord[] { - return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => - toPersistedSavedEnvironmentRecord(record), - ); -} - -export function writeSavedEnvironmentRegistry( - registryPath: string, - records: readonly PersistedSavedEnvironmentRecord[], -): void { - const currentDocument = readSavedEnvironmentRegistryDocument(registryPath); - const encryptedBearerTokenById = new Map( - currentDocument.records.flatMap((record) => - record.encryptedBearerToken - ? [[record.environmentId, record.encryptedBearerToken] as const] - : [], - ), - ); - writeJsonFile(registryPath, { - records: records.map((record) => { - const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); - return encryptedBearerToken - ? { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - encryptedBearerToken, - } - : record; - }), - } satisfies SavedEnvironmentRegistryDocument); -} - -export function readSavedEnvironmentSecret(input: { - readonly registryPath: string; - readonly environmentId: string; - readonly secretStorage: DesktopSecretStorage; -}): string | null { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - const encoded = document.records.find( - (record) => record.environmentId === input.environmentId, - )?.encryptedBearerToken; - if (!encoded) { - return null; - } - - if (!input.secretStorage.isEncryptionAvailable()) { - return null; - } - - try { - return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); - } catch { - return null; - } -} - -export function writeSavedEnvironmentSecret(input: { - readonly registryPath: string; - readonly environmentId: string; - readonly secret: string; - readonly secretStorage: DesktopSecretStorage; -}): boolean { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - - if (!input.secretStorage.isEncryptionAvailable()) { - return false; - } - - let found = false; - - writeJsonFile(input.registryPath, { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - found = true; - const encryptedBearerToken = input.secretStorage - .encryptString(input.secret) - .toString("base64"); - return { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - encryptedBearerToken, - } satisfies PersistedSavedEnvironmentStorageRecord; - }), - } satisfies SavedEnvironmentRegistryDocument); - return found; -} - -export function removeSavedEnvironmentSecret(input: { - readonly registryPath: string; - readonly environmentId: string; -}): void { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - if ( - !document.records.some( - (record) => - record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, - ) - ) { - return; - } - - writeJsonFile(input.registryPath, { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - return toPersistedSavedEnvironmentRecord(record); - }), - } satisfies SavedEnvironmentRegistryDocument); -} diff --git a/apps/desktop/src/confirmDialog.test.ts b/apps/desktop/src/confirmDialog.test.ts deleted file mode 100644 index 4a4c0ddbed6..00000000000 --- a/apps/desktop/src/confirmDialog.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { BrowserWindow } from "electron"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { showMessageBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), -})); - -vi.mock("electron", () => ({ - dialog: { - showMessageBox: showMessageBoxMock, - }, -})); - -import { showDesktopConfirmDialog } from "./confirmDialog"; - -describe("showDesktopConfirmDialog", () => { - beforeEach(() => { - showMessageBoxMock.mockReset(); - }); - - it("returns false and does not open a dialog for empty messages", async () => { - const result = await showDesktopConfirmDialog(" ", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).not.toHaveBeenCalled(); - }); - - it("opens a dialog for the focused window and returns true on confirm", async () => { - const ownerWindow = { id: 1 } as BrowserWindow; - showMessageBoxMock.mockResolvedValue({ response: 1 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow); - - expect(result).toBe(true); - expect(showMessageBoxMock).toHaveBeenCalledWith( - ownerWindow, - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); - - it("opens an app-level dialog when there is no focused window", async () => { - showMessageBoxMock.mockResolvedValue({ response: 0 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).toHaveBeenCalledWith( - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); -}); diff --git a/apps/desktop/src/confirmDialog.ts b/apps/desktop/src/confirmDialog.ts deleted file mode 100644 index c941d090652..00000000000 --- a/apps/desktop/src/confirmDialog.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type BrowserWindow, dialog } from "electron"; - -const CONFIRM_BUTTON_INDEX = 1; - -export async function showDesktopConfirmDialog( - message: string, - ownerWindow: BrowserWindow | null, -): Promise { - const normalizedMessage = message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const result = ownerWindow - ? await dialog.showMessageBox(ownerWindow, options) - : await dialog.showMessageBox(options); - return result.response === CONFIRM_BUTTON_INDEX; -} diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts deleted file mode 100644 index e687bf544eb..00000000000 --- a/apps/desktop/src/desktopSettings.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - DEFAULT_DESKTOP_SETTINGS, - readDesktopSettings, - setDesktopServerExposurePreference, - writeDesktopSettings, -} from "./desktopSettings"; - -const tempDirectories: string[] = []; - -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }); - } -}); - -function makeSettingsPath() { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); - tempDirectories.push(directory); - return path.join(directory, "desktop-settings.json"); -} - -describe("desktopSettings", () => { - it("returns defaults when no settings file exists", () => { - expect(readDesktopSettings(makeSettingsPath())).toEqual(DEFAULT_DESKTOP_SETTINGS); - }); - - it("persists and reloads the configured server exposure mode", () => { - const settingsPath = makeSettingsPath(); - - writeDesktopSettings(settingsPath, { - serverExposureMode: "network-accessible", - }); - - expect(readDesktopSettings(settingsPath)).toEqual({ - serverExposureMode: "network-accessible", - }); - }); - - it("preserves the requested network-accessible preference across temporary fallback", () => { - expect( - setDesktopServerExposurePreference( - { - serverExposureMode: "local-only", - }, - "network-accessible", - ), - ).toEqual({ - serverExposureMode: "network-accessible", - }); - }); - - it("falls back to defaults when the settings file is malformed", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync(settingsPath, "{not-json", "utf8"); - - expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); - }); -}); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts deleted file mode 100644 index 80ef229ea23..00000000000 --- a/apps/desktop/src/desktopSettings.ts +++ /dev/null @@ -1,51 +0,0 @@ -import * as FS from "node:fs"; -import * as Path from "node:path"; -import type { DesktopServerExposureMode } from "@t3tools/contracts"; - -export interface DesktopSettings { - readonly serverExposureMode: DesktopServerExposureMode; -} - -export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { - serverExposureMode: "local-only", -}; - -export function setDesktopServerExposurePreference( - settings: DesktopSettings, - requestedMode: DesktopServerExposureMode, -): DesktopSettings { - return settings.serverExposureMode === requestedMode - ? settings - : { - ...settings, - serverExposureMode: requestedMode, - }; -} - -export function readDesktopSettings(settingsPath: string): DesktopSettings { - try { - if (!FS.existsSync(settingsPath)) { - return DEFAULT_DESKTOP_SETTINGS; - } - - const raw = FS.readFileSync(settingsPath, "utf8"); - const parsed = JSON.parse(raw) as { - readonly serverExposureMode?: unknown; - }; - - return { - serverExposureMode: - parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", - }; - } catch { - return DEFAULT_DESKTOP_SETTINGS; - } -} - -export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { - const directory = Path.dirname(settingsPath); - const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; - FS.mkdirSync(directory, { recursive: true }); - FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); - FS.renameSync(tempPath, settingsPath); -} diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts new file mode 100644 index 00000000000..c51157a2364 --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -0,0 +1,109 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { + appendSwitchMock, + exitMock, + getAppPathMock, + getVersionMock, + onMock, + quitMock, + relaunchMock, + removeListenerMock, + setAboutPanelOptionsMock, + setAppUserModelIdMock, + setDesktopNameMock, + setDockIconMock, + setNameMock, + setPathMock, + whenReadyMock, +} = vi.hoisted(() => ({ + appendSwitchMock: vi.fn(), + exitMock: vi.fn(), + getAppPathMock: vi.fn(() => "/app"), + getVersionMock: vi.fn(() => "1.2.3"), + onMock: vi.fn(), + quitMock: vi.fn(), + relaunchMock: vi.fn(), + removeListenerMock: vi.fn(), + setAboutPanelOptionsMock: vi.fn(), + setAppUserModelIdMock: vi.fn(), + setDesktopNameMock: vi.fn(), + setDockIconMock: vi.fn(), + setNameMock: vi.fn(), + setPathMock: vi.fn(), + whenReadyMock: vi.fn(() => Promise.resolve()), +})); + +vi.mock("electron", () => ({ + app: { + commandLine: { + appendSwitch: appendSwitchMock, + }, + dock: { + setIcon: setDockIconMock, + }, + getAppPath: getAppPathMock, + getVersion: getVersionMock, + isPackaged: true, + name: "T3 Code", + on: onMock, + quit: quitMock, + relaunch: relaunchMock, + removeListener: removeListenerMock, + runningUnderARM64Translation: false, + setAboutPanelOptions: setAboutPanelOptionsMock, + setAppUserModelId: setAppUserModelIdMock, + setDesktopName: setDesktopNameMock, + setName: setNameMock, + setPath: setPathMock, + whenReady: whenReadyMock, + exit: exitMock, + }, +})); + +import * as ElectronApp from "./ElectronApp.ts"; + +describe("ElectronApp", () => { + beforeEach(() => { + appendSwitchMock.mockClear(); + exitMock.mockClear(); + onMock.mockClear(); + quitMock.mockClear(); + relaunchMock.mockClear(); + removeListenerMock.mockClear(); + setPathMock.mockClear(); + }); + + it.effect("reads app metadata through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const metadata = yield* electronApp.metadata; + + assert.deepEqual(metadata, { + appVersion: "1.2.3", + appPath: "/app", + isPackaged: true, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: false, + }); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("scopes app event listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.on("activate", listener); + }), + ); + + assert.deepEqual(onMock.mock.calls, [["activate", listener]]); + assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts new file mode 100644 index 00000000000..2e330c2d275 --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -0,0 +1,118 @@ +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 interface ElectronAppMetadata { + readonly appVersion: string; + readonly appPath: string; + readonly isPackaged: boolean; + readonly resourcesPath: string; + readonly runningUnderArm64Translation: boolean; +} + +export interface ElectronAppShape { + readonly metadata: Effect.Effect; + readonly name: Effect.Effect; + readonly whenReady: Effect.Effect; + readonly quit: Effect.Effect; + readonly exit: (code: number) => Effect.Effect; + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; + readonly setPath: ( + name: Parameters[0], + path: string, + ) => Effect.Effect; + readonly setName: (name: string) => Effect.Effect; + readonly setAboutPanelOptions: ( + options: Electron.AboutPanelOptionsOptions, + ) => Effect.Effect; + readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly setDesktopName: (desktopName: string) => Effect.Effect; + readonly setDockIcon: (iconPath: string) => Effect.Effect; + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; +} + +export class ElectronApp extends Context.Service()( + "t3/desktop/electron/App", +) {} + +const addScopedAppListener = >( + eventName: string, + listener: (...args: Args) => void, +): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => { + Electron.app.on(eventName as any, listener as any); + }), + () => + Effect.sync(() => { + Electron.app.removeListener(eventName as any, listener as any); + }), + ).pipe(Effect.asVoid); + +const make = ElectronApp.of({ + metadata: Effect.sync(() => ({ + appVersion: Electron.app.getVersion(), + appPath: Electron.app.getAppPath(), + isPackaged: Electron.app.isPackaged, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, + })), + name: Effect.sync(() => Electron.app.name), + whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), + quit: Effect.sync(() => { + Electron.app.quit(); + }), + exit: (code) => + Effect.sync(() => { + Electron.app.exit(code); + }), + relaunch: (options) => + Effect.sync(() => { + Electron.app.relaunch(options); + }), + setPath: (name, path) => + Effect.sync(() => { + Electron.app.setPath(name, path); + }), + setName: (name) => + Effect.sync(() => { + Electron.app.setName(name); + }), + setAboutPanelOptions: (options) => + Effect.sync(() => { + Electron.app.setAboutPanelOptions(options); + }), + setAppUserModelId: (id) => + Effect.sync(() => { + Electron.app.setAppUserModelId(id); + }), + setDesktopName: (desktopName) => + Effect.sync(() => { + const linuxApp = Electron.app as Electron.App & { + setDesktopName?: (desktopName: string) => void; + }; + linuxApp.setDesktopName?.(desktopName); + }), + setDockIcon: (iconPath) => + Effect.sync(() => { + Electron.app.dock?.setIcon(iconPath); + }), + appendCommandLineSwitch: (switchName, value) => + Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }), + on: addScopedAppListener, +}); + +export const layer = Layer.succeed(ElectronApp, make); diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts new file mode 100644 index 00000000000..61b40bcfc4c --- /dev/null +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -0,0 +1,93 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type { BrowserWindow } from "electron"; +import { beforeEach, vi } from "vitest"; + +import * as ElectronDialog from "./ElectronDialog.ts"; + +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ + showMessageBoxMock: vi.fn(), + showOpenDialogMock: vi.fn(), + showErrorBoxMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + dialog: { + showMessageBox: showMessageBoxMock, + showOpenDialog: showOpenDialogMock, + showErrorBox: showErrorBoxMock, + }, +})); + +describe("ElectronDialog", () => { + beforeEach(() => { + showMessageBoxMock.mockReset(); + showOpenDialogMock.mockReset(); + showErrorBoxMock.mockReset(); + }); + + it.effect("returns false without opening a confirm dialog for empty messages", () => + Effect.gen(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: " ", + owner: Option.none(), + }); + + assert.isFalse(result); + assert.equal(showMessageBoxMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("opens a confirm dialog for the owner window", () => + Effect.gen(function* () { + const owner = { id: 1 } as BrowserWindow; + showMessageBoxMock.mockResolvedValue({ response: 1 }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: "Delete worktree?", + owner: Option.some(owner), + }); + + assert.isTrue(result); + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ + owner, + { + type: "question", + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: "Delete worktree?", + }, + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("opens an app-level confirm dialog when there is no owner window", () => + Effect.gen(function* () { + showMessageBoxMock.mockResolvedValue({ response: 0 }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: "Delete worktree?", + owner: Option.none(), + }); + + assert.isFalse(result); + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ + { + type: "question", + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: "Delete worktree?", + }, + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts new file mode 100644 index 00000000000..5a4fdfd7ac4 --- /dev/null +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -0,0 +1,84 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +const CONFIRM_BUTTON_INDEX = 1; + +export interface ElectronDialogPickFolderInput { + readonly owner: Option.Option; + readonly defaultPath: Option.Option; +} + +export interface ElectronDialogConfirmInput { + readonly owner: Option.Option; + readonly message: string; +} + +export interface ElectronDialogShape { + readonly pickFolder: ( + input: ElectronDialogPickFolderInput, + ) => Effect.Effect>; + readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + readonly showMessageBox: ( + options: Electron.MessageBoxOptions, + ) => Effect.Effect; + readonly showErrorBox: (title: string, content: string) => Effect.Effect; +} + +export class ElectronDialog extends Context.Service()( + "t3/desktop/electron/Dialog", +) {} + +const make = ElectronDialog.of({ + pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + onNone: () => ({ + properties: ["openDirectory", "createDirectory"], + }), + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], + defaultPath, + }), + }); + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), + onSome: (owner) => + Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + }); + + if (result.canceled) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { + const normalizedMessage = input.message.trim(); + if (normalizedMessage.length === 0) { + return false; + } + + const options = { + type: "question" as const, + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), + onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + }); + return result.response === CONFIRM_BUTTON_INDEX; + }), + showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), + showErrorBox: (title, content) => + Effect.sync(() => { + Electron.dialog.showErrorBox(title, content); + }), +}); + +export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts new file mode 100644 index 00000000000..0e66c5a6f3f --- /dev/null +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -0,0 +1,119 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( + () => ({ + buildFromTemplateMock: vi.fn(), + createFromNamedImageMock: vi.fn(), + setApplicationMenuMock: vi.fn(), + }), +); + +vi.mock("electron", () => ({ + Menu: { + buildFromTemplate: buildFromTemplateMock, + setApplicationMenu: setApplicationMenuMock, + }, + nativeImage: { + createFromNamedImage: createFromNamedImageMock, + }, +})); + +import * as ElectronMenu from "./ElectronMenu.ts"; + +describe("ElectronMenu", () => { + beforeEach(() => { + buildFromTemplateMock.mockReset(); + createFromNamedImageMock.mockReset(); + setApplicationMenuMock.mockReset(); + }); + + it.effect("returns none without building a menu when there are no valid items", () => + Effect.gen(function* () { + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [], + position: Option.none(), + }); + + assert.isTrue(Option.isNone(selectedItemId)); + assert.equal(buildFromTemplateMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("resolves with the clicked leaf item id", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation( + (template: Electron.MenuItemConstructorOptions[]) => ({ + popup: () => { + const firstItem = template[0]; + assert.isDefined(firstItem); + const click = firstItem.click; + if (!click) { + throw new Error("Expected menu item to have a click handler."); + } + click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + }, + }), + ); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.none(), + }); + + assert.equal(Option.getOrNull(selectedItemId), "copy"); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("resolves with none when the menu closes without a click", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation(() => ({ + popup: (options: Electron.PopupOptions) => { + options.callback?.(); + }, + })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.some({ x: 10.8, y: 20.2 }), + }); + + assert.isTrue(Option.isNone(selectedItemId)); + assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { + label: "Copy", + enabled: true, + click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, + }); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("defers popupTemplate side effects until the returned Effect runs", () => + Effect.gen(function* () { + const popupMock = vi.fn(); + buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const popup = electronMenu.popupTemplate({ + window: {} as Electron.BrowserWindow, + template: [{ label: "Copy" }], + }); + + assert.equal(buildFromTemplateMock.mock.calls.length, 0); + assert.equal(popupMock.mock.calls.length, 0); + + yield* popup; + + assert.equal(buildFromTemplateMock.mock.calls.length, 1); + assert.equal(popupMock.mock.calls.length, 1); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts new file mode 100644 index 00000000000..7164fdb54c1 --- /dev/null +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -0,0 +1,181 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +export interface ElectronMenuPosition { + readonly x: number; + readonly y: number; +} + +export interface ElectronMenuContextInput { + readonly window: Electron.BrowserWindow; + readonly items: readonly ContextMenuItem[]; + readonly position: Option.Option; +} + +export interface ElectronMenuTemplateInput { + readonly window: Electron.BrowserWindow; + readonly template: readonly Electron.MenuItemConstructorOptions[]; +} + +export interface ElectronMenuShape { + readonly setApplicationMenu: ( + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; + readonly showContextMenu: ( + input: ElectronMenuContextInput, + ) => Effect.Effect>; + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; +} + +export class ElectronMenu extends Context.Service()( + "t3/desktop/electron/Menu", +) {} + +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} + +const normalizePosition = ( + position: Option.Option, +): Option.Option => + Option.filter( + position, + ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, + ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + +export const layer = Layer.sync(ElectronMenu, () => { + let destructiveMenuIconCache: Option.Option | undefined; + + const getDestructiveMenuIcon = (): Option.Option => { + if (process.platform !== "darwin") { + return Option.none(); + } + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; + } + + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } + + return destructiveMenuIconCache; + }; + + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } + } + + template.push(itemOption); + } + + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); + return; + } + + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { + return; + } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); +}); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts new file mode 100644 index 00000000000..955813d6d35 --- /dev/null +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } = + vi.hoisted(() => ({ + registerFileProtocolMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + unregisterProtocolMock: vi.fn(), + })); + +vi.mock("electron", () => ({ + protocol: { + registerFileProtocol: registerFileProtocolMock, + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, + unregisterProtocol: unregisterProtocolMock, + }, +})); + +import * as ElectronProtocol from "./ElectronProtocol.ts"; + +describe("ElectronProtocol", () => { + beforeEach(() => { + registerFileProtocolMock.mockReset(); + registerSchemesAsPrivilegedMock.mockReset(); + unregisterProtocolMock.mockReset(); + }); + + it("normalizes safe desktop protocol pathnames", () => { + assert.equal( + Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")), + "settings/general", + ); + assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); + }); + + it.effect("registers desktop scheme privileges through a layer", () => + Effect.scoped( + Layer.build(ElectronProtocol.layerSchemePrivileges).pipe( + Effect.andThen( + Effect.sync(() => { + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ + [ + [ + { + scheme: "t3", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ], + ], + ]); + }), + ), + ), + ), + ); + + it.effect("scopes registered file protocols", () => + Effect.gen(function* () { + let capturedHandler: + | (( + request: Electron.ProtocolRequest, + callback: (response: Electron.ProtocolResponse) => void, + ) => void) + | undefined; + + registerFileProtocolMock.mockImplementation((_scheme, handler) => { + capturedHandler = handler; + return true; + }); + + const response = yield* Effect.scoped( + Effect.gen(function* () { + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerFileProtocol({ + scheme: "t3", + handler: () => Effect.succeed({ path: "/app/index.html" }), + }); + + assert.isDefined(capturedHandler); + return yield* Effect.callback((resume) => { + capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) => + resume(Effect.succeed(response)), + ); + }); + }), + ); + + assert.deepEqual(response, { path: "/app/index.html" }); + assert.deepEqual( + registerFileProtocolMock.mock.calls.map((call) => call[0]), + ["t3"], + ); + assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts new file mode 100644 index 00000000000..32d23ba485d --- /dev/null +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -0,0 +1,272 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts"; + +export const DESKTOP_SCHEME = "t3"; + +export class ElectronProtocolRegistrationError extends Data.TaggedError( + "ElectronProtocolRegistrationError", +)<{ + readonly scheme: string; + readonly cause: unknown; +}> { + override get message() { + return `Failed to register ${this.scheme}: file protocol.`; + } +} + +export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( + "ElectronProtocolStaticBundleMissingError", +)<{}> { + override get message() { + return "Desktop static bundle missing. Build apps/server (with bundled client) first."; + } +} + +export interface ElectronProtocolShape { + readonly registerFileProtocol: (input: { + readonly scheme: string; + readonly handler: ( + request: Electron.ProtocolRequest, + ) => Effect.Effect; + readonly onFailure?: ( + request: Electron.ProtocolRequest, + cause: Cause.Cause, + ) => Electron.ProtocolResponse; + }) => Effect.Effect; + readonly registerDesktopFileProtocol: Effect.Effect< + void, + ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError, + FileSystem.FileSystem | DesktopEnvironment | Scope.Scope + >; +} + +export class ElectronProtocol extends Context.Service()( + "t3/desktop/electron/Protocol", +) {} + +export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { + const segments: string[] = []; + for (const segment of rawPath.split("/")) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + return Option.none(); + } + segments.push(segment); + } + return Option.some(segments.join("/")); +} + +const registerDesktopSchemePrivileges = Effect.sync(() => { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ]); +}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); + +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); + +const resolveDesktopStaticDir: Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidates = [ + environment.path.join(environment.appRoot, "apps/server/dist/client"), + environment.path.join(environment.appRoot, "apps/web/dist"), + ]; + for (const candidate of candidates) { + const hasIndex = yield* fileSystem + .exists(environment.path.join(candidate, "index.html")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasIndex) { + return Option.some(candidate); + } + } + return Option.none(); +}); + +const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")( + function* ( + staticRoot: string, + requestUrl: string, + ): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const url = new URL(requestUrl); + const rawPath = decodeURIComponent(url.pathname); + const normalizedPath = normalizeDesktopProtocolPathname(rawPath); + if (Option.isNone(normalizedPath)) { + return environment.path.join(staticRoot, "index.html"); + } + + const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; + const resolvedPath = environment.path.join(staticRoot, requestedPath); + + if (environment.path.extname(resolvedPath)) { + return resolvedPath; + } + + const nestedIndex = environment.path.join(resolvedPath, "index.html"); + const nestedIndexExists = yield* fileSystem + .exists(nestedIndex) + .pipe(Effect.orElseSucceed(() => false)); + if (nestedIndexExists) { + return nestedIndex; + } + + return environment.path.join(staticRoot, "index.html"); + }, +); + +function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { + try { + const url = new URL(requestUrl); + return environment.path.extname(url.pathname).length > 0; + } catch { + return false; + } +} + +const make = Effect.gen(function* () { + const registeredProtocols = yield* Ref.make>(new Set()); + + const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")( + function* ({ + scheme, + handler, + onFailure, + }: { + readonly scheme: string; + readonly handler: ( + request: Electron.ProtocolRequest, + ) => Effect.Effect; + readonly onFailure?: ( + request: Electron.ProtocolRequest, + cause: Cause.Cause, + ) => Electron.ProtocolResponse; + }): Effect.fn.Return { + yield* Effect.annotateCurrentSpan({ scheme }); + const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( + Effect.map((protocols) => protocols.has(scheme)), + ); + if (alreadyRegistered) { + return; + } + + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* Effect.acquireRelease( + Effect.try({ + try: () => { + const registered = Electron.protocol.registerFileProtocol( + scheme, + (request, callback) => { + const response = handler(request).pipe( + Effect.withSpan("desktop.electron.protocol.handleFileRequest"), + Effect.catchCause((cause) => + Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), + ), + ); + + void runPromise(response).then(callback, () => callback({ error: -2 })); + }, + ); + if (!registered) { + throw new ElectronProtocolRegistrationError({ + scheme, + cause: "registerFileProtocol returned false", + }); + } + }, + catch: (cause) => + cause instanceof ElectronProtocolRegistrationError + ? cause + : new ElectronProtocolRegistrationError({ scheme, cause }), + }).pipe( + Effect.andThen( + Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), + ), + ), + () => + Effect.sync(() => { + Electron.protocol.unregisterProtocol(scheme); + }).pipe( + Effect.andThen( + Ref.update(registeredProtocols, (protocols) => { + const next = new Set(protocols); + next.delete(scheme); + return next; + }), + ), + ), + ); + }, + ); + + const registerDesktopFileProtocol = Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (environment.isDevelopment) return; + + const staticRoot = yield* resolveDesktopStaticDir; + if (Option.isNone(staticRoot)) { + return yield* new ElectronProtocolStaticBundleMissingError(); + } + + const staticRootResolved = environment.path.resolve(staticRoot.value); + const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; + const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); + + yield* registerFileProtocol({ + scheme: DESKTOP_SCHEME, + handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); + const resolvedCandidate = environment.path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url, environment); + const exists = yield* fileSystem + .exists(resolvedCandidate) + .pipe(Effect.orElseSucceed(() => false)); + + if (!isInRoot || !exists) { + return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); + } + + return { path: resolvedCandidate } as const; + }), + onFailure: () => ({ path: fallbackIndex }), + }); + }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol")); + + return ElectronProtocol.of({ + registerFileProtocol, + registerDesktopFileProtocol, + }); +}); + +export const layer = Layer.effect(ElectronProtocol, make); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts new file mode 100644 index 00000000000..eebb3e2b2f8 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -0,0 +1,70 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( + "ElectronSafeStorageAvailabilityError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to check encryption availability."; + } +} + +export class ElectronSafeStorageEncryptError extends Data.TaggedError( + "ElectronSafeStorageEncryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to encrypt a string."; + } +} + +export class ElectronSafeStorageDecryptError extends Data.TaggedError( + "ElectronSafeStorageDecryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to decrypt a string."; + } +} + +export interface ElectronSafeStorageShape { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; +} + +export class ElectronSafeStorage extends Context.Service< + ElectronSafeStorage, + ElectronSafeStorageShape +>()("@t3tools/desktop/ElectronSafeStorage") {} + +const make = ElectronSafeStorage.of({ + isEncryptionAvailable: Effect.try({ + try: () => Electron.safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), + }), + encryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), +}); + +export const layer = Layer.succeed(ElectronSafeStorage, make); diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts new file mode 100644 index 00000000000..42d1bb33add --- /dev/null +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -0,0 +1,59 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ + openExternalMock: vi.fn(), + writeTextMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + shell: { + openExternal: openExternalMock, + }, + clipboard: { + writeText: writeTextMock, + }, +})); + +import * as ElectronShell from "./ElectronShell.ts"; + +describe("ElectronShell", () => { + beforeEach(() => { + openExternalMock.mockReset(); + writeTextMock.mockReset(); + }); + + it.effect("opens safe external URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("https://example.com/path"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open unsafe external URLs", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("file:///etc/passwd"); + + assert.equal(result, false); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("returns false when Electron rejects openExternal", () => + Effect.gen(function* () { + openExternalMock.mockRejectedValue(new Error("open failed")); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("https://example.com/path"); + + assert.equal(result, false); + }).pipe(Effect.provide(ElectronShell.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts new file mode 100644 index 00000000000..09826a95b09 --- /dev/null +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -0,0 +1,50 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); + +export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { + if (typeof rawUrl !== "string") { + return Option.none(); + } + + try { + const url = new URL(rawUrl); + return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none(); + } catch { + return Option.none(); + } +} + +export interface ElectronShellShape { + readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; +} + +export class ElectronShell extends Context.Service()( + "t3/desktop/electron/Shell", +) {} + +const make = ElectronShell.of({ + openExternal: (rawUrl) => + Option.match(parseSafeExternalUrl(rawUrl), { + onNone: () => Effect.succeed(false), + onSome: (externalUrl) => + Effect.promise(() => + Electron.shell.openExternal(externalUrl).then( + () => true, + () => false, + ), + ), + }), + copyText: (text) => + Effect.sync(() => { + Electron.clipboard.writeText(text); + }), +}); + +export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts new file mode 100644 index 00000000000..c52882852ff --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -0,0 +1,52 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ + onMock: vi.fn(), + removeListenerMock: vi.fn(), + themeState: { + shouldUseDarkColors: true, + themeSource: "system", + }, +})); + +vi.mock("electron", () => ({ + nativeTheme: { + get shouldUseDarkColors() { + return themeState.shouldUseDarkColors; + }, + set themeSource(value: string) { + themeState.themeSource = value; + }, + on: onMock, + removeListener: removeListenerMock, + }, +})); + +import * as ElectronTheme from "./ElectronTheme.ts"; + +describe("ElectronTheme", () => { + beforeEach(() => { + onMock.mockClear(); + removeListenerMock.mockClear(); + themeState.shouldUseDarkColors = true; + themeState.themeSource = "system"; + }); + + it.effect("scopes native theme update listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + yield* electronTheme.onUpdated(listener); + }), + ); + + assert.deepEqual(onMock.mock.calls, [["updated", listener]]); + assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts new file mode 100644 index 00000000000..ecf1f98dade --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -0,0 +1,40 @@ +import type { DesktopTheme } from "@t3tools/contracts"; +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 interface ElectronThemeShape { + readonly shouldUseDarkColors: Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly onUpdated: (listener: () => void) => Effect.Effect; +} + +export class ElectronTheme extends Context.Service()( + "t3/desktop/electron/Theme", +) {} + +const make = ElectronTheme.of({ + shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), + setSource: (theme) => + Effect.suspend(() => { + Electron.nativeTheme.themeSource = theme; + return Effect.void; + }), + onUpdated: (listener) => + Effect.acquireRelease( + Effect.suspend(() => { + Electron.nativeTheme.on("updated", listener); + return Effect.void; + }), + () => + Effect.suspend(() => { + Electron.nativeTheme.removeListener("updated", listener); + return Effect.void; + }), + ), +}); + +export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts new file mode 100644 index 00000000000..eec21a9ae56 --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { autoUpdaterMock } = vi.hoisted(() => ({ + autoUpdaterMock: { + allowDowngrade: false, + allowPrerelease: false, + autoDownload: true, + autoInstallOnAppQuit: true, + channel: "latest", + disableDifferentialDownload: false, + checkForUpdates: vi.fn(() => Promise.resolve(null)), + downloadUpdate: vi.fn(() => Promise.resolve([])), + on: vi.fn(), + quitAndInstall: vi.fn(), + removeListener: vi.fn(), + setFeedURL: vi.fn(), + }, +})); + +vi.mock("electron-updater", () => ({ + autoUpdater: autoUpdaterMock, +})); + +import * as ElectronUpdater from "./ElectronUpdater.ts"; + +describe("ElectronUpdater", () => { + beforeEach(() => { + autoUpdaterMock.allowDowngrade = false; + autoUpdaterMock.allowPrerelease = false; + autoUpdaterMock.autoDownload = true; + autoUpdaterMock.autoInstallOnAppQuit = true; + autoUpdaterMock.channel = "latest"; + autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.checkForUpdates.mockClear(); + autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); + autoUpdaterMock.downloadUpdate.mockClear(); + autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); + autoUpdaterMock.on.mockClear(); + autoUpdaterMock.quitAndInstall.mockClear(); + autoUpdaterMock.removeListener.mockClear(); + autoUpdaterMock.setFeedURL.mockClear(); + }); + + it.effect("scopes updater event listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + yield* updater.on("update-available", listener); + }), + ); + + assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); + assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("wraps rejected update checks in the method-specific typed error", () => + Effect.gen(function* () { + const cause = new Error("network unavailable"); + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); + const updater = yield* ElectronUpdater.ElectronUpdater; + + const exit = yield* Effect.exit(updater.checkForUpdates); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.equal(error.cause, cause); + } + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts new file mode 100644 index 00000000000..ad8afbcdfc3 --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -0,0 +1,139 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import { autoUpdater } from "electron-updater"; + +type AutoUpdater = typeof autoUpdater; + +export type ElectronUpdaterFeedUrl = Parameters[0]; + +export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( + "ElectronUpdaterCheckForUpdatesError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to check for updates."; + } +} + +export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( + "ElectronUpdaterDownloadUpdateError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to download the update."; + } +} + +export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( + "ElectronUpdaterQuitAndInstallError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to quit and install the update."; + } +} + +export type ElectronUpdaterError = + | ElectronUpdaterCheckForUpdatesError + | ElectronUpdaterDownloadUpdateError + | ElectronUpdaterQuitAndInstallError; + +export interface ElectronUpdaterShape { + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; + readonly setAutoDownload: (value: boolean) => Effect.Effect; + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly setChannel: (channel: string) => Effect.Effect; + readonly setAllowPrerelease: (value: boolean) => Effect.Effect; + readonly allowDowngrade: Effect.Effect; + readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; + readonly quitAndInstall: (options: { + readonly isSilent: boolean; + readonly isForceRunAfter: boolean; + }) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; +} + +export class ElectronUpdater extends Context.Service()( + "t3/desktop/electron/Updater", +) {} + +export const layer = Layer.succeed(ElectronUpdater, { + setFeedURL: (options) => + Effect.suspend(() => { + autoUpdater.setFeedURL(options); + return Effect.void; + }), + setAutoDownload: (value) => + Effect.suspend(() => { + autoUpdater.autoDownload = value; + return Effect.void; + }), + setAutoInstallOnAppQuit: (value) => + Effect.suspend(() => { + autoUpdater.autoInstallOnAppQuit = value; + return Effect.void; + }), + setChannel: (channel) => + Effect.suspend(() => { + autoUpdater.channel = channel; + return Effect.void; + }), + setAllowPrerelease: (value) => + Effect.suspend(() => { + autoUpdater.allowPrerelease = value; + return Effect.void; + }), + allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), + setAllowDowngrade: (value) => + Effect.suspend(() => { + autoUpdater.allowDowngrade = value; + return Effect.void; + }), + setDisableDifferentialDownload: (value) => + Effect.suspend(() => { + autoUpdater.disableDifferentialDownload = value; + return Effect.void; + }), + checkForUpdates: Effect.tryPromise({ + try: () => autoUpdater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), + }).pipe(Effect.asVoid), + downloadUpdate: Effect.tryPromise({ + try: () => autoUpdater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), + }).pipe(Effect.asVoid), + quitAndInstall: ({ isSilent, isForceRunAfter }) => + Effect.try({ + try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), + }), + on: (eventName, listener) => { + const eventTarget = autoUpdater as unknown as { + on: (eventName: string, listener: (...args: Array) => void) => void; + removeListener: (eventName: string, listener: (...args: Array) => void) => void; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + eventTarget.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); + }, +} satisfies ElectronUpdaterShape); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts new file mode 100644 index 00000000000..bc8a4cdd282 --- /dev/null +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -0,0 +1,51 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ + appFocusMock: vi.fn(), + getAllWindowsMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { + focus: appFocusMock, + }, + BrowserWindow: { + getAllWindows: getAllWindowsMock, + }, +})); + +import * as ElectronWindow from "./ElectronWindow.ts"; + +function makeBrowserWindow(input: { readonly destroyed: boolean }) { + return { + isDestroyed: vi.fn(() => input.destroyed), + } as unknown as Electron.BrowserWindow; +} + +describe("ElectronWindow", () => { + beforeEach(() => { + appFocusMock.mockReset(); + getAllWindowsMock.mockReset(); + }); + + it.effect("skips windows destroyed before appearance sync runs", () => + Effect.gen(function* () { + const liveWindow = makeBrowserWindow({ destroyed: false }); + const destroyedWindow = makeBrowserWindow({ destroyed: true }); + getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); + + const syncedWindows: Electron.BrowserWindow[] = []; + const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* electronWindow.syncAllAppearance((window) => + Effect.sync(() => { + syncedWindows.push(window); + }), + ); + + assert.deepEqual(syncedWindows, [liveWindow]); + }).pipe(Effect.provide(ElectronWindow.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts new file mode 100644 index 00000000000..ed31fb0700e --- /dev/null +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -0,0 +1,135 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 Electron from "electron"; + +export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ + readonly cause: unknown; +}> { + override get message() { + return "Failed to create Electron BrowserWindow."; + } +} + +export interface ElectronWindowShape { + readonly create: ( + options: Electron.BrowserWindowConstructorOptions, + ) => Effect.Effect; + readonly main: Effect.Effect>; + readonly currentMainOrFirst: Effect.Effect>; + readonly focusedMainOrFirst: Effect.Effect>; + readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; + readonly clearMain: (window: Option.Option) => Effect.Effect; + readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; + readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; + readonly destroyAll: Effect.Effect; + readonly syncAllAppearance: ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) => Effect.Effect; +} + +export class ElectronWindow extends Context.Service()( + "t3/desktop/electron/Window", +) {} + +const make = Effect.gen(function* () { + const mainWindowRef = yield* Ref.make>(Option.none()); + + const liveMain = Ref.get(mainWindowRef).pipe( + Effect.map(Option.filter((value) => !value.isDestroyed())), + ); + + const currentMainOrFirst = Effect.gen(function* () { + const main = yield* liveMain; + if (Option.isSome(main)) { + return main; + } + + return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( + Option.filter((window) => !window.isDestroyed()), + ); + }); + + const focusedMainOrFirst = Effect.sync(() => + Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( + Option.filter((window) => !window.isDestroyed()), + ), + ).pipe( + Effect.flatMap((focused) => + Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst, + ), + ); + + return ElectronWindow.of({ + create: (options) => + Effect.try({ + try: () => new Electron.BrowserWindow(options), + catch: (cause) => new ElectronWindowCreateError({ cause }), + }), + main: liveMain, + currentMainOrFirst, + focusedMainOrFirst, + setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), + clearMain: (window) => + Ref.update(mainWindowRef, (current) => { + if (Option.isNone(current)) { + return current; + } + if (Option.isSome(window) && current.value !== window.value) { + return current; + } + return Option.none(); + }), + reveal: (window) => + Effect.sync(() => { + if (window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + if (process.platform === "darwin") { + Electron.app.focus({ steal: true }); + } + + window.focus(); + }), + sendAll: (channel, ...args) => + Effect.sync(() => { + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue; + } + window.webContents.send(channel, ...args); + } + }), + destroyAll: Effect.sync(() => { + for (const window of Electron.BrowserWindow.getAllWindows()) { + window.destroy(); + } + }), + syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) { + const windows = Electron.BrowserWindow.getAllWindows(); + for (const window of windows) { + if (window.isDestroyed()) { + continue; + } + yield* sync(window); + } + }), + }); +}); + +export const layer = Layer.effect(ElectronWindow, make); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts new file mode 100644 index 00000000000..05a0f25512e --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -0,0 +1,219 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +export interface DesktopIpcInvokeEvent {} + +export interface DesktopIpcSyncEvent { + returnValue: unknown; +} + +export type DesktopIpcHandleListener = ( + event: DesktopIpcInvokeEvent, + raw: unknown, +) => unknown | Promise; + +export type DesktopIpcSyncListener = (event: DesktopIpcSyncEvent) => void; + +export interface DesktopIpcMain { + removeHandler(channel: string): void; + handle(channel: string, listener: DesktopIpcHandleListener): void; + removeAllListeners(channel: string): void; + on(channel: string, listener: DesktopIpcSyncListener): void; +} + +export interface DesktopIpcMethod { + readonly channel: string; + readonly handler: (raw: unknown) => Effect.Effect; +} + +export interface DesktopSyncIpcMethod { + readonly channel: string; + readonly handler: () => Effect.Effect; +} + +export interface DesktopIpcShape { + readonly handle: ( + input: DesktopIpcMethod, + ) => Effect.Effect; + readonly handleSync: ( + input: DesktopSyncIpcMethod, + ) => Effect.Effect; +} + +export class DesktopIpc extends Context.Service()("t3/desktop/Ipc") {} + +export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => + DesktopIpc.of({ + handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ + channel, + handler, + }: DesktopIpcMethod) { + yield* Effect.annotateCurrentSpan({ channel }); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, (_event, raw) => + runPromise( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(raw); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), + ), + ); + }), + () => Effect.sync(() => ipcMain.removeHandler(channel)), + ); + }), + + handleSync: Effect.fn("desktop.ipc.registerSync")(function* ({ + channel, + handler, + }: DesktopSyncIpcMethod) { + yield* Effect.annotateCurrentSpan({ channel }); + const context = yield* Effect.context(); + const runSync = Effect.runSyncWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeAllListeners(channel); + ipcMain.on(channel, (event) => { + event.returnValue = runSync( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")), + ); + }); + }), + () => Effect.sync(() => ipcMain.removeAllListeners(channel)), + ); + }), + }); + +/** + * Convenience helpers for creating IPC methods + */ + +export interface DesktopIpcMethodRegistration< + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices = never, + PayloadEncodingServices = never, + ResultDecodingServices = never, + ResultEncodingServices = never, +> { + readonly channel: string; + readonly payload: Schema.Codec< + Payload, + EncodedPayload, + PayloadDecodingServices, + PayloadEncodingServices + >; + readonly result: Schema.Codec< + Result, + EncodedResult, + ResultDecodingServices, + ResultEncodingServices + >; + readonly handler: (input: Payload) => Effect.Effect; +} + +export const makeIpcMethod = < + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices = never, + PayloadEncodingServices = never, + ResultDecodingServices = never, + ResultEncodingServices = never, +>( + method: DesktopIpcMethodRegistration< + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices, + PayloadEncodingServices, + ResultDecodingServices, + ResultEncodingServices + >, +): DesktopIpcMethod< + E | Schema.SchemaError, + R | PayloadDecodingServices | ResultEncodingServices +> => { + const decode = Schema.decodeUnknownEffect(method.payload); + const encode = Schema.encodeUnknownEffect(method.result); + + return { + channel: method.channel, + handler: (raw) => + decode(raw).pipe( + Effect.flatMap(method.handler), + Effect.flatMap(encode), + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), + ), + }; +}; + +export interface DesktopSyncIpcMethodRegistration< + Result, + EncodedResult, + E, + R, + ResultDecodingServices = never, + ResultEncodingServices = never, +> { + readonly channel: string; + readonly result: Schema.Codec< + Result, + EncodedResult, + ResultDecodingServices, + ResultEncodingServices + >; + readonly handler: () => Effect.Effect; +} + +export const makeSyncIpcMethod = < + Result, + EncodedResult, + E, + R, + ResultDecodingServices = never, + ResultEncodingServices = never, +>( + method: DesktopSyncIpcMethodRegistration< + Result, + EncodedResult, + E, + R, + ResultDecodingServices, + ResultEncodingServices + >, +): DesktopSyncIpcMethod => { + const encode = Schema.encodeUnknownEffect(method.result); + + return { + channel: method.channel, + handler: () => + method + .handler() + .pipe( + Effect.flatMap(encode), + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), + ), + }; +}; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts new file mode 100644 index 00000000000..8717c877951 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -0,0 +1,84 @@ +import * as Effect from "effect/Effect"; + +import * as DesktopIpc from "./DesktopIpc.ts"; +import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { + getSavedEnvironmentRegistry, + getSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + setSavedEnvironmentRegistry, + setSavedEnvironmentSecret, +} from "./methods/savedEnvironments.ts"; +import { + getAdvertisedEndpoints, + getServerExposureState, + setServerExposureMode, + setTailscaleServeEnabled, +} from "./methods/serverExposure.ts"; +import { + bootstrapSshBearerSession, + disconnectSshEnvironment, + discoverSshHosts, + ensureSshEnvironment, + fetchSshEnvironmentDescriptor, + fetchSshSessionState, + issueSshWebSocketToken, + resolveSshPasswordPrompt, +} from "./methods/sshEnvironment.ts"; +import { + checkForUpdate, + downloadUpdate, + getUpdateState, + installUpdate, + setUpdateChannel, +} from "./methods/updates.ts"; +import { + confirm, + getAppBranding, + getLocalEnvironmentBootstrap, + openExternal, + pickFolder, + setTheme, + showContextMenu, +} from "./methods/window.ts"; + +export const installDesktopIpcHandlers = Effect.gen(function* () { + const ipc = yield* DesktopIpc.DesktopIpc; + + yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getLocalEnvironmentBootstrap); + + yield* ipc.handle(getClientSettings); + yield* ipc.handle(setClientSettings); + yield* ipc.handle(getSavedEnvironmentRegistry); + yield* ipc.handle(setSavedEnvironmentRegistry); + yield* ipc.handle(getSavedEnvironmentSecret); + yield* ipc.handle(setSavedEnvironmentSecret); + yield* ipc.handle(removeSavedEnvironmentSecret); + + yield* ipc.handle(discoverSshHosts); + yield* ipc.handle(ensureSshEnvironment); + yield* ipc.handle(disconnectSshEnvironment); + yield* ipc.handle(fetchSshEnvironmentDescriptor); + yield* ipc.handle(bootstrapSshBearerSession); + yield* ipc.handle(fetchSshSessionState); + yield* ipc.handle(issueSshWebSocketToken); + yield* ipc.handle(resolveSshPasswordPrompt); + + yield* ipc.handle(getServerExposureState); + yield* ipc.handle(setServerExposureMode); + yield* ipc.handle(setTailscaleServeEnabled); + yield* ipc.handle(getAdvertisedEndpoints); + + yield* ipc.handle(pickFolder); + yield* ipc.handle(confirm); + yield* ipc.handle(setTheme); + yield* ipc.handle(showContextMenu); + yield* ipc.handle(openExternal); + + yield* ipc.handle(getUpdateState); + yield* ipc.handle(setUpdateChannel); + yield* ipc.handle(downloadUpdate); + yield* ipc.handle(installUpdate); + yield* ipc.handle(checkForUpdate); +}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts new file mode 100644 index 00000000000..2715b20cb36 --- /dev/null +++ b/apps/desktop/src/ipc/channels.ts @@ -0,0 +1,35 @@ +export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const CONFIRM_CHANNEL = "desktop:confirm"; +export const SET_THEME_CHANNEL = "desktop:set-theme"; +export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; +export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const UPDATE_STATE_CHANNEL = "desktop:update-state"; +export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; +export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; +export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; +export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; +export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; +export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; +export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; +export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; +export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; +export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts new file mode 100644 index 00000000000..52b173266cd --- /dev/null +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -0,0 +1,28 @@ +import { ClientSettingsSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getClientSettings = makeIpcMethod({ + channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(ClientSettingsSchema), + handler: Effect.fn("desktop.ipc.clientSettings.get")(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + return Option.getOrNull(yield* clientSettings.get); + }), +}); + +export const setClientSettings = makeIpcMethod({ + channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, + payload: ClientSettingsSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + yield* clientSettings.set(settings); + }), +}); diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts new file mode 100644 index 00000000000..bc5e4a9aeb2 --- /dev/null +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -0,0 +1,76 @@ +import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); +const NonBlankString = Schema.String.check( + Schema.makeFilter((value) => + value.trim().length > 0 ? undefined : "Expected a non-empty string", + ), +); + +const SetSavedEnvironmentSecretInput = Schema.Struct({ + environmentId: EnvironmentId, + secret: NonBlankString, +}); + +export const getSavedEnvironmentRegistry = makeIpcMethod({ + channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + payload: Schema.Void, + result: SavedEnvironmentRegistryPayload, + handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.getRegistry; + }), +}); + +export const setSavedEnvironmentRegistry = makeIpcMethod({ + channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + payload: SavedEnvironmentRegistryPayload, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry(records); + }), +}); + +export const getSavedEnvironmentSecret = makeIpcMethod({ + channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: EnvironmentId, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); + }), +}); + +export const setSavedEnvironmentSecret = makeIpcMethod({ + channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: SetSavedEnvironmentSecretInput, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({ + environmentId, + secret, + }) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.setSecret({ + environmentId, + secret, + }); + }), +}); + +export const removeSavedEnvironmentSecret = makeIpcMethod({ + channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: EnvironmentId, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.removeSecret(environmentId); + }), +}); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts new file mode 100644 index 00000000000..cd0f215e193 --- /dev/null +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -0,0 +1,69 @@ +import { + AdvertisedEndpoint, + DesktopServerExposureModeSchema, + DesktopServerExposureStateSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const SetTailscaleServeEnabledInput = Schema.Struct({ + enabled: Schema.Boolean, + port: Schema.optionalKey(Schema.Number), +}); + +export const getServerExposureState = makeIpcMethod({ + channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.getState")(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + return yield* serverExposure.getState; + }), +}); + +export const setServerExposureMode = makeIpcMethod({ + channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, + payload: DesktopServerExposureModeSchema, + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.setMode")(function* (mode) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setMode(mode); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch(`serverExposureMode=${mode}`); + } + return change.state; + }), +}); + +export const setTailscaleServeEnabled = makeIpcMethod({ + channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, + payload: SetTailscaleServeEnabledInput, + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.setTailscaleServeEnabled")(function* (input) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setTailscaleServeEnabled(input); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch( + change.state.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", + ); + } + return change.state; + }), +}); + +export const getAdvertisedEndpoints = makeIpcMethod({ + channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(AdvertisedEndpoint), + handler: Effect.fn("desktop.ipc.serverExposure.getAdvertisedEndpoints")(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + return yield* serverExposure.getAdvertisedEndpoints; + }), +}); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts new file mode 100644 index 00000000000..efea3dd132d --- /dev/null +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -0,0 +1,127 @@ +import { + DesktopDiscoveredSshHostSchema, + DesktopSshBearerBootstrapInputSchema, + DesktopSshBearerRequestInputSchema, + DesktopSshEnvironmentEnsureInputSchema, + DesktopSshEnvironmentEnsureResultSchema, + DesktopSshEnvironmentTargetSchema, + DesktopSshHttpBaseUrlInputSchema, + DesktopSshPasswordPromptCancelledType, + DesktopSshPasswordPromptResolutionInputSchema, + ExecutionEnvironmentDescriptor, + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts"; + +export const discoverSshHosts = makeIpcMethod({ + channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(DesktopDiscoveredSshHostSchema), + handler: Effect.fn("desktop.ipc.sshEnvironment.discoverHosts")(function* () { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.discoverHosts(); + }), +}); + +export const ensureSshEnvironment = makeIpcMethod({ + channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, + payload: DesktopSshEnvironmentEnsureInputSchema, + result: DesktopSshEnvironmentEnsureResultSchema, + handler: Effect.fn("desktop.ipc.sshEnvironment.ensureEnvironment")(function* ({ + target, + options, + }) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.ensureEnvironment(target, options).pipe( + Effect.catch((error) => + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) + ? Effect.succeed({ + type: DesktopSshPasswordPromptCancelledType, + message: error.message, + }) + : Effect.fail(error), + ), + ); + }), +}); + +export const disconnectSshEnvironment = makeIpcMethod({ + channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + payload: DesktopSshEnvironmentTargetSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + yield* sshEnvironment.disconnectEnvironment(target); + }), +}); + +export const fetchSshEnvironmentDescriptor = makeIpcMethod({ + channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + payload: DesktopSshHttpBaseUrlInputSchema, + result: ExecutionEnvironmentDescriptor, + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); + }), +}); + +export const bootstrapSshBearerSession = makeIpcMethod({ + channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + payload: DesktopSshBearerBootstrapInputSchema, + result: AuthBearerBootstrapResult, + handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({ + httpBaseUrl, + credential, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); + }), +}); + +export const fetchSshSessionState = makeIpcMethod({ + channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, + payload: DesktopSshBearerRequestInputSchema, + result: AuthSessionState, + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchSessionState")(function* ({ + httpBaseUrl, + bearerToken, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); + }), +}); + +export const issueSshWebSocketToken = makeIpcMethod({ + channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + payload: DesktopSshBearerRequestInputSchema, + result: AuthWebSocketTokenResult, + handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({ + httpBaseUrl, + bearerToken, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); + }), +}); + +export const resolveSshPasswordPrompt = makeIpcMethod({ + channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + payload: DesktopSshPasswordPromptResolutionInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.sshEnvironment.resolvePasswordPrompt")(function* ({ + requestId, + password, + }) { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + yield* prompts.resolve({ requestId, password }); + }), +}); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts new file mode 100644 index 00000000000..45ea8502121 --- /dev/null +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -0,0 +1,62 @@ +import { + DesktopUpdateActionResultSchema, + DesktopUpdateChannelSchema, + DesktopUpdateCheckResultSchema, + DesktopUpdateStateSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopUpdates from "../../updates/DesktopUpdates.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getUpdateState = makeIpcMethod({ + channel: IpcChannels.UPDATE_GET_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateStateSchema, + handler: Effect.fn("desktop.ipc.updates.getState")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.getState; + }), +}); + +export const setUpdateChannel = makeIpcMethod({ + channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, + payload: DesktopUpdateChannelSchema, + result: DesktopUpdateStateSchema, + handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.setChannel(channel); + }), +}); + +export const downloadUpdate = makeIpcMethod({ + channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateActionResultSchema, + handler: Effect.fn("desktop.ipc.updates.download")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.download; + }), +}); + +export const installUpdate = makeIpcMethod({ + channel: IpcChannels.UPDATE_INSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateActionResultSchema, + handler: Effect.fn("desktop.ipc.updates.install")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.install; + }), +}); + +export const checkForUpdate = makeIpcMethod({ + channel: IpcChannels.UPDATE_CHECK_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateCheckResultSchema, + handler: Effect.fn("desktop.ipc.updates.check")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.check("web-ui"); + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts new file mode 100644 index 00000000000..1cb4d7265a1 --- /dev/null +++ b/apps/desktop/src/ipc/methods/window.ts @@ -0,0 +1,135 @@ +import { + ContextMenuItemSchema, + DesktopAppBrandingSchema, + DesktopEnvironmentBootstrapSchema, + DesktopThemeSchema, + PickFolderOptionsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../../electron/ElectronMenu.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +const ContextMenuPosition = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, +}); + +const ContextMenuInput = Schema.Struct({ + items: Schema.Array(ContextMenuItemSchema), + position: Schema.optionalKey(ContextMenuPosition), +}); + +function toWebSocketBaseUrl(httpBaseUrl: URL): string { + const url = new URL(httpBaseUrl.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} + +export const getAppBranding = makeSyncIpcMethod({ + channel: IpcChannels.GET_APP_BRANDING_CHANNEL, + result: Schema.NullOr(DesktopAppBrandingSchema), + handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.branding; + }), +}); + +export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, + result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const config = yield* backendManager.currentConfig; + return Option.match(config, { + onNone: () => null, + onSome: ({ bootstrap, httpBaseUrl }) => ({ + label: "Local environment", + httpBaseUrl: httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), + ...(bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }), + }); + }), +}); + +export const pickFolder = makeIpcMethod({ + channel: IpcChannels.PICK_FOLDER_CHANNEL, + payload: Schema.UndefinedOr(PickFolderOptionsSchema), + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.pickFolder")(function* (options) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const selectedPath = yield* dialog.pickFolder({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: environment.resolvePickFolderDefaultPath(options), + }); + return Option.getOrNull(selectedPath); + }), +}); + +export const confirm = makeIpcMethod({ + channel: IpcChannels.CONFIRM_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return yield* electronWindow.focusedMainOrFirst.pipe( + Effect.flatMap((owner) => dialog.confirm({ owner, message })), + ); + }), +}); + +export const setTheme = makeIpcMethod({ + channel: IpcChannels.SET_THEME_CHANNEL, + payload: DesktopThemeSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.setTheme")(function* (theme) { + const electronTheme = yield* ElectronTheme.ElectronTheme; + yield* electronTheme.setSource(theme); + }), +}); + +export const showContextMenu = makeIpcMethod({ + channel: IpcChannels.CONTEXT_MENU_CHANNEL, + payload: ContextMenuInput, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.showContextMenu")(function* (input) { + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.focusedMainOrFirst; + if (Option.isNone(window)) { + return null; + } + + const selectedItemId = yield* electronMenu.showContextMenu({ + window: window.value, + items: input.items, + position: Option.fromNullishOr(input.position), + }); + return Option.getOrNull(selectedItemId); + }), +}); + +export const openExternal = makeIpcMethod({ + channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openExternal(url); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef30d3b4bbb..0bc1badff2d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,1910 +1,154 @@ -import * as ChildProcess from "node:child_process"; -import * as Crypto from "node:crypto"; -import * as FS from "node:fs"; -import * as OS from "node:os"; -import * as Path from "node:path"; - -import { - app, - BrowserWindow, - clipboard, - dialog, - ipcMain, - Menu, - nativeImage, - nativeTheme, - protocol, - safeStorage, - shell, -} from "electron"; -import type { MenuItemConstructorOptions } from "electron"; -import type { - ClientSettings, - DesktopTheme, - DesktopServerExposureMode, - DesktopServerExposureState, - PersistedSavedEnvironmentRecord, - DesktopUpdateActionResult, - DesktopUpdateCheckResult, - DesktopUpdateState, -} from "@t3tools/contracts"; -import { autoUpdater } from "electron-updater"; - -import type { ContextMenuItem } from "@t3tools/contracts"; -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; -import { - DEFAULT_DESKTOP_SETTINGS, - readDesktopSettings, - setDesktopServerExposurePreference, - writeDesktopSettings, -} from "./desktopSettings"; -import { - readClientSettings, - readSavedEnvironmentRegistry, - readSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - writeClientSettings, - writeSavedEnvironmentRegistry, - writeSavedEnvironmentSecret, -} from "./clientPersistence"; -import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; -import { showDesktopConfirmDialog } from "./confirmDialog"; -import { resolveDesktopServerExposure } from "./serverExposure"; -import { syncShellEnvironment } from "./syncShellEnvironment"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; -import { - createInitialDesktopUpdateState, - reduceDesktopUpdateStateOnCheckFailure, - reduceDesktopUpdateStateOnCheckStart, - reduceDesktopUpdateStateOnDownloadComplete, - reduceDesktopUpdateStateOnDownloadFailure, - reduceDesktopUpdateStateOnDownloadProgress, - reduceDesktopUpdateStateOnDownloadStart, - reduceDesktopUpdateStateOnInstallFailure, - reduceDesktopUpdateStateOnNoUpdate, - reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; - -syncShellEnvironment(); - -const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -const CONFIRM_CHANNEL = "desktop:confirm"; -const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; -const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const MENU_ACTION_CHANNEL = "desktop:menu-action"; -const UPDATE_STATE_CHANNEL = "desktop:update-state"; -const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; -const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; -const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; -const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; -const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; -const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; -const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; -const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); -const STATE_DIR = Path.join(BASE_DIR, "userdata"); -const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); -const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); -const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); -const DESKTOP_SCHEME = "t3"; -const ROOT_DIR = Path.resolve(__dirname, "../../.."); -const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; -const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; -const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; -const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; -const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; -const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; -const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; -const COMMIT_HASH_DISPLAY_LENGTH = 12; -const LOG_DIR = Path.join(STATE_DIR, "logs"); -const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; -const LOG_FILE_MAX_FILES = 10; -const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); -const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); -const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; -const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; -const DESKTOP_UPDATE_CHANNEL = "latest"; -const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; -const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; - -type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; -type LinuxDesktopNamedApp = Electron.App & { - setDesktopName?: (desktopName: string) => void; -}; - -let mainWindow: BrowserWindow | null = null; -let backendProcess: ChildProcess.ChildProcess | null = null; -let backendPort = 0; -let backendBindHost = DESKTOP_LOOPBACK_HOST; -let backendBootstrapToken = ""; -let backendHttpUrl = ""; -let backendWsUrl = ""; -let backendEndpointUrl: string | null = null; -let backendAdvertisedHost: string | null = null; -let backendReadinessAbortController: AbortController | null = null; -let restartAttempt = 0; -let restartTimer: ReturnType | null = null; -let isQuitting = false; -let desktopProtocolRegistered = false; -let aboutCommitHashCache: string | null | undefined; -let desktopLogSink: RotatingFileSink | null = null; -let backendLogSink: RotatingFileSink | null = null; -let restoreStdIoCapture: (() => void) | null = null; -let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); -let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); -let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; - -let destructiveMenuIconCache: Electron.NativeImage | null | undefined; -const expectedBackendExitChildren = new WeakSet(); -const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ - platform: process.platform, - processArch: process.arch, - runningUnderArm64Translation: app.runningUnderARM64Translation === true, -}); -const initialUpdateState = (): DesktopUpdateState => - createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); - -function logTimestamp(): string { - return new Date().toISOString(); -} - -function logScope(scope: string): string { - return `${scope} run=${APP_RUN_ID}`; -} - -function sanitizeLogValue(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -function readPersistedBackendObservabilitySettings(): { - readonly otlpTracesUrl: string | undefined; - readonly otlpMetricsUrl: string | undefined; -} { - try { - if (!FS.existsSync(SERVER_SETTINGS_PATH)) { - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } - return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); - } catch (error) { - console.warn("[desktop] failed to read persisted backend observability settings", error); - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } -} - -function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { - if (!rawPort) { - return undefined; - } - - const parsedPort = Number.parseInt(rawPort, 10); - if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { - return undefined; - } - - return parsedPort; -} - -function resolveDesktopDevServerUrl(): string { - const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); - if (!devServerUrl) { - throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); - } - - return devServerUrl; -} - -function backendChildEnv(): NodeJS.ProcessEnv { - const env = { ...process.env }; - delete env.T3CODE_PORT; - delete env.T3CODE_MODE; - delete env.T3CODE_NO_BROWSER; - delete env.T3CODE_HOST; - delete env.T3CODE_DESKTOP_WS_URL; - delete env.T3CODE_DESKTOP_LAN_ACCESS; - delete env.T3CODE_DESKTOP_LAN_HOST; - return env; -} - -function getDesktopServerExposureState(): DesktopServerExposureState { - return { - mode: desktopServerExposureMode, - endpointUrl: backendEndpointUrl, - advertisedHost: backendAdvertisedHost, - }; -} - -function getDesktopSecretStorage() { - return { - isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), - encryptString: (value: string) => safeStorage.encryptString(value), - decryptString: (value: Buffer) => safeStorage.decryptString(value), - } as const; -} - -function resolveAdvertisedHostOverride(): string | undefined { - const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); - return override && override.length > 0 ? override : undefined; -} - -async function applyDesktopServerExposureMode( - mode: DesktopServerExposureMode, - options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, -): Promise { - const advertisedHostOverride = resolveAdvertisedHostOverride(); - const requestedMode = mode; - let exposure = resolveDesktopServerExposure({ - mode, - port: backendPort, - networkInterfaces: OS.networkInterfaces(), - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }); - - if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { - if (options?.rejectIfUnavailable) { - throw new Error("No reachable network address is available for this desktop right now."); - } - exposure = resolveDesktopServerExposure({ - mode: "local-only", - port: backendPort, - networkInterfaces: OS.networkInterfaces(), - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }); - } - - desktopServerExposureMode = exposure.mode; - desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); - backendBindHost = exposure.bindHost; - backendHttpUrl = exposure.localHttpUrl; - backendWsUrl = exposure.localWsUrl; - backendEndpointUrl = exposure.endpointUrl; - backendAdvertisedHost = exposure.advertisedHost; - - if (options?.persist) { - writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); - } - - return getDesktopServerExposureState(); -} - -function relaunchDesktopApp(reason: string): void { - writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); - setImmediate(() => { - isQuitting = true; - clearUpdatePollTimer(); - cancelBackendReadinessWait(); - void stopBackendAndWaitForExit() - .catch((error) => { - writeDesktopLogHeader( - `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, - ); - }) - .finally(() => { - restoreStdIoCapture?.(); - if (isDevelopment) { - app.exit(75); - return; - } - app.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - app.exit(0); - }); - }); -} - -function writeDesktopLogHeader(message: string): void { - if (!desktopLogSink) return; - desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); -} - -function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { - if (!backendLogSink) return; - const normalizedDetails = sanitizeLogValue(details); - backendLogSink.write( - `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`, - ); -} - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function getSafeExternalUrl(rawUrl: unknown): string | null { - if (typeof rawUrl !== "string" || rawUrl.length === 0) { - return null; - } - - let parsedUrl: URL; - try { - parsedUrl = new URL(rawUrl); - } catch { - return null; - } - - if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { - return null; - } - - return parsedUrl.toString(); -} - -function getSafeTheme(rawTheme: unknown): DesktopTheme | null { - if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") { - return rawTheme; - } - - return null; -} - -async function waitForBackendHttpReady(baseUrl: string): Promise { - cancelBackendReadinessWait(); - const controller = new AbortController(); - backendReadinessAbortController = controller; - - try { - await waitForHttpReady(baseUrl, { - signal: controller.signal, - }); - } finally { - if (backendReadinessAbortController === controller) { - backendReadinessAbortController = null; - } - } -} - -function cancelBackendReadinessWait(): void { - backendReadinessAbortController?.abort(); - backendReadinessAbortController = null; -} - -function writeDesktopStreamChunk( - streamName: "stdout" | "stderr", - chunk: unknown, - encoding: BufferEncoding | undefined, -): void { - if (!desktopLogSink) return; - const buffer = Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined); - desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `); - desktopLogSink.write(buffer); - if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) { - desktopLogSink.write("\n"); - } -} - -function installStdIoCapture(): void { - if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) { - return; - } - - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - const patchWrite = - (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) => - ( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ): boolean => { - const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; - writeDesktopStreamChunk(streamName, chunk, encoding); - if (typeof encodingOrCallback === "function") { - return originalWrite(chunk, encodingOrCallback); - } - if (callback !== undefined) { - return originalWrite(chunk, encoding, callback); - } - if (encoding !== undefined) { - return originalWrite(chunk, encoding); - } - return originalWrite(chunk); - }; - - process.stdout.write = patchWrite("stdout", originalStdoutWrite); - process.stderr.write = patchWrite("stderr", originalStderrWrite); - - restoreStdIoCapture = () => { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - restoreStdIoCapture = null; - }; -} - -function initializePackagedLogging(): void { - if (!app.isPackaged) return; - try { - desktopLogSink = new RotatingFileSink({ - filePath: Path.join(LOG_DIR, "desktop-main.log"), - maxBytes: LOG_FILE_MAX_BYTES, - maxFiles: LOG_FILE_MAX_FILES, - }); - backendLogSink = new RotatingFileSink({ - filePath: Path.join(LOG_DIR, "server-child.log"), - maxBytes: LOG_FILE_MAX_BYTES, - maxFiles: LOG_FILE_MAX_FILES, - }); - installStdIoCapture(); - writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`); - } catch (error) { - // Logging setup should never block app startup. - console.error("[desktop] failed to initialize packaged logging", error); - } -} - -function captureBackendOutput(child: ChildProcess.ChildProcess): void { - if (!app.isPackaged || backendLogSink === null) return; - const writeChunk = (chunk: unknown): void => { - if (!backendLogSink) return; - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); - backendLogSink.write(buffer); - }; - child.stdout?.on("data", writeChunk); - child.stderr?.on("data", writeChunk); -} - -initializePackagedLogging(); - -if (process.platform === "linux") { - app.commandLine.appendSwitch("class", LINUX_WM_CLASS); -} - -function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache ?? undefined; - } - try { - const icon = nativeImage.createFromNamedImage("trash").resize({ - width: 14, - height: 14, - }); - if (icon.isEmpty()) { - destructiveMenuIconCache = null; - return undefined; - } - icon.setTemplateImage(true); - destructiveMenuIconCache = icon; - return icon; - } catch { - destructiveMenuIconCache = null; - return undefined; - } -} -let updatePollTimer: ReturnType | null = null; -let updateStartupTimer: ReturnType | null = null; -let updateCheckInFlight = false; -let updateDownloadInFlight = false; -let updateInstallInFlight = false; -let updaterConfigured = false; -let updateState: DesktopUpdateState = initialUpdateState(); - -function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { - if (updateInstallInFlight) return "install"; - if (updateDownloadInFlight) return "download"; - if (updateCheckInFlight) return "check"; - return updateState.errorContext; -} - -protocol.registerSchemesAsPrivileged([ - { - scheme: DESKTOP_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, -]); - -function resolveAppRoot(): string { - if (!app.isPackaged) { - return ROOT_DIR; - } - return app.getAppPath(); -} - -/** Read the baked-in app-update.yml config (if applicable). */ -function readAppUpdateYml(): Record | null { - try { - // electron-updater reads from process.resourcesPath in packaged builds, - // or dev-app-update.yml via app.getAppPath() in dev. - const ymlPath = app.isPackaged - ? Path.join(process.resourcesPath, "app-update.yml") - : Path.join(app.getAppPath(), "dev-app-update.yml"); - const raw = FS.readFileSync(ymlPath, "utf-8"); - // The YAML is simple key-value pairs — avoid pulling in a YAML parser by - // doing a line-based parse (fields: provider, owner, repo, releaseType, …). - const entries: Record = {}; - for (const line of raw.split("\n")) { - const match = line.match(/^(\w+):\s*(.+)$/); - if (match?.[1] && match[2]) entries[match[1]] = match[2].trim(); - } - return entries.provider ? entries : null; - } catch { - return null; - } -} - -function normalizeCommitHash(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!COMMIT_HASH_PATTERN.test(trimmed)) { - return null; - } - return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase(); -} - -function resolveEmbeddedCommitHash(): string | null { - const packageJsonPath = Path.join(resolveAppRoot(), "package.json"); - if (!FS.existsSync(packageJsonPath)) { - return null; - } - - try { - const raw = FS.readFileSync(packageJsonPath, "utf8"); - const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown }; - return normalizeCommitHash(parsed.t3codeCommitHash); - } catch { - return null; - } -} - -function resolveAboutCommitHash(): string | null { - if (aboutCommitHashCache !== undefined) { - return aboutCommitHashCache; - } - - const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH); - if (envCommitHash) { - aboutCommitHashCache = envCommitHash; - return aboutCommitHashCache; - } - - // Only packaged builds are required to expose commit metadata. - if (!app.isPackaged) { - aboutCommitHashCache = null; - return aboutCommitHashCache; - } - - aboutCommitHashCache = resolveEmbeddedCommitHash(); - - return aboutCommitHashCache; -} - -function resolveBackendEntry(): string { - return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs"); -} - -function resolveBackendCwd(): string { - if (!app.isPackaged) { - return resolveAppRoot(); - } - return OS.homedir(); -} - -function resolveDesktopStaticDir(): string | null { - const appRoot = resolveAppRoot(); - const candidates = [ - Path.join(appRoot, "apps/server/dist/client"), - Path.join(appRoot, "apps/web/dist"), - ]; - - for (const candidate of candidates) { - if (FS.existsSync(Path.join(candidate, "index.html"))) { - return candidate; - } - } - - return null; -} - -function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { - const url = new URL(requestUrl); - const rawPath = decodeURIComponent(url.pathname); - const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, ""); - if (normalizedPath.includes("..")) { - return Path.join(staticRoot, "index.html"); - } - - const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; - const resolvedPath = Path.join(staticRoot, requestedPath); - - if (Path.extname(resolvedPath)) { - return resolvedPath; - } - - const nestedIndex = Path.join(resolvedPath, "index.html"); - if (FS.existsSync(nestedIndex)) { - return nestedIndex; - } - - return Path.join(staticRoot, "index.html"); -} - -function isStaticAssetRequest(requestUrl: string): boolean { - try { - const url = new URL(requestUrl); - return Path.extname(url.pathname).length > 0; - } catch { - return false; - } -} - -function handleFatalStartupError(stage: string, error: unknown): void { - const message = formatErrorMessage(error); - const detail = - error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; - writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`); - console.error(`[desktop] fatal startup error (${stage})`, error); - if (!isQuitting) { - isQuitting = true; - dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); - } - stopBackend(); - restoreStdIoCapture?.(); - app.quit(); -} - -function registerDesktopProtocol(): void { - if (isDevelopment || desktopProtocolRegistered) return; - - const staticRoot = resolveDesktopStaticDir(); - if (!staticRoot) { - throw new Error( - "Desktop static bundle missing. Build apps/server (with bundled client) first.", +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +import * as NetService from "@t3tools/shared/Net"; +import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; +import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import serverPackageJson from "../../server/package.json" with { type: "json" }; + +import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; +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 ElectronProtocol from "./electron/ElectronProtocol.ts"; +import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronShell from "./electron/ElectronShell.ts"; +import * as ElectronTheme from "./electron/ElectronTheme.ts"; +import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; +import * as ElectronWindow from "./electron/ElectronWindow.ts"; +import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; +import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; +import * as DesktopAssets from "./app/DesktopAssets.ts"; +import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopObservability from "./app/DesktopObservability.ts"; +import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; +import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; +import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; +import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; +import * as DesktopState from "./app/DesktopState.ts"; +import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as DesktopWindow from "./window/DesktopWindow.ts"; + +const desktopEnvironmentLayer = Layer.unwrap( + Effect.gen(function* () { + const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( + Effect.flatMap((app) => app.metadata), ); - } - - const staticRootResolved = Path.resolve(staticRoot); - const staticRootPrefix = `${staticRootResolved}${Path.sep}`; - const fallbackIndex = Path.join(staticRootResolved, "index.html"); - - protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { - try { - const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); - const resolvedCandidate = Path.resolve(candidate); - const isInRoot = - resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); - const isAssetRequest = isStaticAssetRequest(request.url); - - if (!isInRoot || !FS.existsSync(resolvedCandidate)) { - if (isAssetRequest) { - callback({ error: -6 }); - return; - } - callback({ path: fallbackIndex }); - return; - } - - callback({ path: resolvedCandidate }); - } catch { - callback({ path: fallbackIndex }); - } - }); - - desktopProtocolRegistered = true; -} - -function dispatchMenuAction(action: string): void { - const existingWindow = - BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; - const targetWindow = existingWindow ?? createWindow(); - if (!existingWindow) { - mainWindow = targetWindow; - } - - const send = () => { - if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - revealWindow(targetWindow); - }; - - if (targetWindow.webContents.isLoadingMainFrame()) { - targetWindow.webContents.once("did-finish-load", send); - return; - } - - send(); -} - -function handleCheckForUpdatesMenuClick(): void { - const hasUpdateFeedConfig = - readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); - const disabledReason = getAutoUpdateDisabledReason({ - isDevelopment, - isPackaged: app.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig, - }); - if (disabledReason) { - console.info("[desktop-updater] Manual update check requested, but updates are disabled."); - void dialog.showMessageBox({ - type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason, - buttons: ["OK"], - }); - return; - } - - if (!BrowserWindow.getAllWindows().length) { - mainWindow = createWindow(); - } - void checkForUpdatesFromMenu(); -} - -async function checkForUpdatesFromMenu(): Promise { - await checkForUpdates("menu"); - - if (updateState.status === "up-to-date") { - void dialog.showMessageBox({ - type: "info", - title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, - buttons: ["OK"], - }); - } else if (updateState.status === "error") { - void dialog.showMessageBox({ - type: "warning", - title: "Update check failed", - message: "Could not check for updates.", - detail: updateState.message ?? "An unknown error occurred. Please try again later.", - buttons: ["OK"], - }); - } -} - -function configureApplicationMenu(): void { - const template: MenuItemConstructorOptions[] = []; - - if (process.platform === "darwin") { - template.push({ - label: app.name, - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - { type: "separator" }, - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => dispatchMenuAction("open-settings"), - }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }); - } - - template.push( - { - label: "File", - submenu: [ - ...(process.platform === "darwin" - ? [] - : [ - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => dispatchMenuAction("open-settings"), - }, - { type: "separator" as const }, - ]), - { role: process.platform === "darwin" ? "close" : "quit" }, - ], - }, - { role: "editMenu" }, - { - label: "View", - submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { role: "windowMenu" }, - { - role: "help", - submenu: [ - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - ], - }, - ); - - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); -} - -function resolveResourcePath(fileName: string): string | null { - const candidates = [ - Path.join(__dirname, "../resources", fileName), - Path.join(__dirname, "../prod-resources", fileName), - Path.join(process.resourcesPath, "resources", fileName), - Path.join(process.resourcesPath, fileName), - ]; - - for (const candidate of candidates) { - if (FS.existsSync(candidate)) { - return candidate; - } - } - - return null; -} - -function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { - return resolveResourcePath(`icon.${ext}`); -} - -/** - * Resolve the Electron userData directory path. - * - * Electron derives the default userData path from `productName` in - * package.json, which currently produces directories with spaces and - * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is - * unfriendly for shell usage and violates Linux naming conventions. - * - * We override it to a clean lowercase name (`t3code`). If the legacy - * directory already exists we keep using it so existing users don't - * lose their Chromium profile data (localStorage, cookies, sessions). - */ -function resolveUserDataPath(): string { - const appDataBase = - process.platform === "win32" - ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming") - : process.platform === "darwin" - ? Path.join(OS.homedir(), "Library", "Application Support") - : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config"); - - const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME); - if (FS.existsSync(legacyPath)) { - return legacyPath; - } - - return Path.join(appDataBase, USER_DATA_DIR_NAME); -} - -function configureAppIdentity(): void { - app.setName(APP_DISPLAY_NAME); - const commitHash = resolveAboutCommitHash(); - app.setAboutPanelOptions({ - applicationName: APP_DISPLAY_NAME, - applicationVersion: app.getVersion(), - version: commitHash ?? "unknown", - }); - - if (process.platform === "win32") { - app.setAppUserModelId(APP_USER_MODEL_ID); - } - - if (process.platform === "linux") { - (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME); - } - - if (process.platform === "darwin" && app.dock) { - const iconPath = resolveIconPath("png"); - if (iconPath) { - app.dock.setIcon(iconPath); - } - } -} - -function clearUpdatePollTimer(): void { - if (updateStartupTimer) { - clearTimeout(updateStartupTimer); - updateStartupTimer = null; - } - if (updatePollTimer) { - clearInterval(updatePollTimer); - updatePollTimer = null; - } -} - -function revealWindow(window: BrowserWindow): void { - if (window.isDestroyed()) { - return; - } - - if (window.isMinimized()) { - window.restore(); - } - - if (!window.isVisible()) { - window.show(); - } - - if (process.platform === "darwin") { - app.focus({ steal: true }); - } - - window.focus(); -} - -function emitUpdateState(): void { - for (const window of BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) continue; - window.webContents.send(UPDATE_STATE_CHANNEL, updateState); - } -} - -function setUpdateState(patch: Partial): void { - updateState = { ...updateState, ...patch }; - emitUpdateState(); -} - -function shouldEnableAutoUpdates(): boolean { - const hasUpdateFeedConfig = - readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); - return ( - getAutoUpdateDisabledReason({ - isDevelopment, - isPackaged: app.isPackaged, + return DesktopEnvironment.layer({ + dirname: __dirname, + homeDirectory: NodeOS.homedir(), platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig, - }) === null - ); -} - -async function checkForUpdates(reason: string): Promise { - if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; - if (updateState.status === "downloading" || updateState.status === "downloaded") { - console.info( - `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, - ); - return false; - } - updateCheckInFlight = true; - setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); - console.info(`[desktop-updater] Checking for updates (${reason})...`); - - try { - await autoUpdater.checkForUpdates(); - return true; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - setUpdateState( - reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), - ); - console.error(`[desktop-updater] Failed to check for updates: ${message}`); - return true; - } finally { - updateCheckInFlight = false; - } -} - -async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> { - if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { - return { accepted: false, completed: false }; - } - updateDownloadInFlight = true; - setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); - console.info("[desktop-updater] Downloading update..."); - - try { - await autoUpdater.downloadUpdate(); - return { accepted: true, completed: true }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); - console.error(`[desktop-updater] Failed to download update: ${message}`); - return { accepted: true, completed: false }; - } finally { - updateDownloadInFlight = false; - } -} - -async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> { - if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { - return { accepted: false, completed: false }; - } - - isQuitting = true; - updateInstallInFlight = true; - clearUpdatePollTimer(); - try { - await stopBackendAndWaitForExit(); - // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close. - for (const win of BrowserWindow.getAllWindows()) { - win.destroy(); - } - // `quitAndInstall()` only starts the handoff to the updater. The actual - // install may still fail asynchronously, so keep the action incomplete - // until we either quit or receive an updater error. - autoUpdater.quitAndInstall(true, true); - return { accepted: true, completed: false }; - } catch (error: unknown) { - const message = formatErrorMessage(error); - updateInstallInFlight = false; - isQuitting = false; - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - console.error(`[desktop-updater] Failed to install update: ${message}`); - return { accepted: true, completed: false }; - } -} - -function configureAutoUpdater(): void { - const githubToken = - process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; - if (githubToken) { - // When a token is provided, re-configure the feed with `private: true` so - // electron-updater uses the GitHub API (api.github.com) instead of the - // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth. - const appUpdateYml = readAppUpdateYml(); - if (appUpdateYml?.provider === "github") { - autoUpdater.setFeedURL({ - ...appUpdateYml, - provider: "github" as const, - private: true, - token: githubToken, - }); - } - } - - if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { - autoUpdater.setFeedURL({ - provider: "generic", - url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, + processArch: process.arch, + ...metadata, }); - } - - const enabled = shouldEnableAutoUpdates(); - setUpdateState({ - ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), - enabled, - status: enabled ? "idle" : "disabled", - }); - if (!enabled) { - return; - } - updaterConfigured = true; - - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - // Keep alpha branding, but force all installs onto the stable update track. - autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; - autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; - autoUpdater.allowDowngrade = false; - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); - let lastLoggedDownloadMilestone = -1; - - if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) { - console.info( - "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.", - ); - } - - autoUpdater.on("checking-for-update", () => { - console.info("[desktop-updater] Looking for updates..."); - }); - autoUpdater.on("update-available", (info) => { - setUpdateState( - reduceDesktopUpdateStateOnUpdateAvailable( - updateState, - info.version, - new Date().toISOString(), - ), - ); - lastLoggedDownloadMilestone = -1; - console.info(`[desktop-updater] Update available: ${info.version}`); - }); - autoUpdater.on("update-not-available", () => { - setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); - lastLoggedDownloadMilestone = -1; - console.info("[desktop-updater] No updates available."); - }); - autoUpdater.on("error", (error) => { - const message = formatErrorMessage(error); - if (updateInstallInFlight) { - updateInstallInFlight = false; - isQuitting = false; - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - console.error(`[desktop-updater] Updater error: ${message}`); - return; - } - if (!updateCheckInFlight && !updateDownloadInFlight) { - setUpdateState({ - status: "error", - message, - checkedAt: new Date().toISOString(), - downloadPercent: null, - errorContext: resolveUpdaterErrorContext(), - canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, - }); - } - console.error(`[desktop-updater] Updater error: ${message}`); - }); - autoUpdater.on("download-progress", (progress) => { - const percent = Math.floor(progress.percent); - if ( - shouldBroadcastDownloadProgress(updateState, progress.percent) || - updateState.message !== null - ) { - setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); - } - const milestone = percent - (percent % 10); - if (milestone > lastLoggedDownloadMilestone) { - lastLoggedDownloadMilestone = milestone; - console.info(`[desktop-updater] Download progress: ${percent}%`); - } - }); - autoUpdater.on("update-downloaded", (info) => { - setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); - console.info(`[desktop-updater] Update downloaded: ${info.version}`); - }); - - clearUpdatePollTimer(); - - updateStartupTimer = setTimeout(() => { - updateStartupTimer = null; - void checkForUpdates("startup"); - }, AUTO_UPDATE_STARTUP_DELAY_MS); - updateStartupTimer.unref(); - - updatePollTimer = setInterval(() => { - void checkForUpdates("poll"); - }, AUTO_UPDATE_POLL_INTERVAL_MS); - updatePollTimer.unref(); -} -function scheduleBackendRestart(reason: string): void { - if (isQuitting || restartTimer) return; - - const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); - restartAttempt += 1; - console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); - - restartTimer = setTimeout(() => { - restartTimer = null; - startBackend(); - }, delayMs); -} - -function startBackend(): void { - if (isQuitting || backendProcess) return; - - backendObservabilitySettings = readPersistedBackendObservabilitySettings(); - const backendEntry = resolveBackendEntry(); - if (!FS.existsSync(backendEntry)) { - scheduleBackendRestart(`missing server entry at ${backendEntry}`); - return; - } - - const captureBackendLogs = app.isPackaged && backendLogSink !== null; - const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { - cwd: resolveBackendCwd(), - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. - env: { - ...backendChildEnv(), - ELECTRON_RUN_AS_NODE: "1", - }, - stdio: captureBackendLogs - ? ["ignore", "pipe", "pipe", "pipe"] - : ["ignore", "inherit", "inherit", "pipe"], - }); - const bootstrapStream = child.stdio[3]; - if (bootstrapStream && "write" in bootstrapStream) { - bootstrapStream.write( - `${JSON.stringify({ - mode: "desktop", - noBrowser: true, - port: backendPort, - t3Home: BASE_DIR, - host: backendBindHost, - desktopBootstrapToken: backendBootstrapToken, - ...(backendObservabilitySettings.otlpTracesUrl - ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } - : {}), - ...(backendObservabilitySettings.otlpMetricsUrl - ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } - : {}), - })}\n`, - ); - bootstrapStream.end(); - } else { - child.kill("SIGTERM"); - scheduleBackendRestart("missing desktop bootstrap pipe"); - return; - } - backendProcess = child; - let backendSessionClosed = false; - const closeBackendSession = (details: string) => { - if (backendSessionClosed) return; - backendSessionClosed = true; - writeBackendSessionBoundary("END", details); - }; - writeBackendSessionBoundary( - "START", - `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, - ); - captureBackendOutput(child); - - child.once("spawn", () => { - restartAttempt = 0; - }); - - child.on("error", (error) => { - const wasExpected = expectedBackendExitChildren.has(child); - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); - if (wasExpected) { - return; - } - scheduleBackendRestart(error.message); - }); - - child.on("exit", (code, signal) => { - const wasExpected = expectedBackendExitChildren.has(child); - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession( - `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, - ); - if (isQuitting || wasExpected) return; - const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; - scheduleBackendRestart(reason); - }); -} - -function stopBackend(): void { - cancelBackendReadinessWait(); - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - - if (child.exitCode === null && child.signalCode === null) { - expectedBackendExitChildren.add(child); - child.kill("SIGTERM"); - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }, 2_000).unref(); - } -} - -async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { - cancelBackendReadinessWait(); - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - const backendChild = child; - if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; - expectedBackendExitChildren.add(backendChild); - - await new Promise((resolve) => { - let settled = false; - let forceKillTimer: ReturnType | null = null; - let exitTimeoutTimer: ReturnType | null = null; - - function settle(): void { - if (settled) return; - settled = true; - backendChild.off("exit", onExit); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - if (exitTimeoutTimer) { - clearTimeout(exitTimeoutTimer); - } - resolve(); - } - - function onExit(): void { - settle(); - } - - backendChild.once("exit", onExit); - backendChild.kill("SIGTERM"); - - forceKillTimer = setTimeout(() => { - if (backendChild.exitCode === null && backendChild.signalCode === null) { - backendChild.kill("SIGKILL"); - } - }, 2_000); - forceKillTimer.unref(); - - exitTimeoutTimer = setTimeout(() => { - settle(); - }, timeoutMs); - exitTimeoutTimer.unref(); - }); -} - -function registerIpcHandlers(): void { - ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); - ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { - event.returnValue = { - label: "Local environment", - httpBaseUrl: backendHttpUrl || null, - wsBaseUrl: backendWsUrl || null, - bootstrapToken: backendBootstrapToken || undefined, - } as const; - }); - - ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); - - ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { - if (typeof rawSettings !== "object" || rawSettings === null) { - throw new Error("Invalid client settings payload."); - } - - writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); - }); - - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => - readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH), - ); - - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => { - if (!Array.isArray(rawRecords)) { - throw new Error("Invalid saved environment registry payload."); - } - - writeSavedEnvironmentRegistry( - SAVED_ENVIRONMENT_REGISTRY_PATH, - rawRecords as readonly PersistedSavedEnvironmentRecord[], - ); - }); - - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return null; - } - - return readSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - secretStorage: getDesktopSecretStorage(), - }); - }, - ); - - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - throw new Error("Invalid saved environment id."); - } - if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { - throw new Error("Invalid saved environment secret."); - } - - return writeSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - secret: rawSecret, - secretStorage: getDesktopSecretStorage(), - }); - }, - ); - - ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return; - } - - removeSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - }); - }, - ); - - ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); - ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); - - ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); - ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { - if (rawMode !== "local-only" && rawMode !== "network-accessible") { - throw new Error("Invalid desktop server exposure input."); - } - - const nextMode = rawMode as DesktopServerExposureMode; - if (nextMode === desktopServerExposureMode) { - return getDesktopServerExposureState(); - } - - const nextState = await applyDesktopServerExposureMode(nextMode, { - persist: true, - rejectIfUnavailable: true, - }); - relaunchDesktopApp(`serverExposureMode=${nextMode}`); - return nextState; - }); - - ipcMain.removeHandler(PICK_FOLDER_CHANNEL); - ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openDirectory", "createDirectory"], - }) - : await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); - if (result.canceled) return null; - return result.filePaths[0] ?? null; - }); - - ipcMain.removeHandler(CONFIRM_CHANNEL); - ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { - if (typeof message !== "string") { - return false; - } - - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - return showDesktopConfirmDialog(message, owner); - }); - - ipcMain.removeHandler(SET_THEME_CHANNEL); - ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { - const theme = getSafeTheme(rawTheme); - if (!theme) { - return; - } - - nativeTheme.themeSource = theme; - }); - - ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle( - CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - disabled: item.disabled === true, - })); - if (normalizedItems.length === 0) { - return null; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); - } - - const menu = Menu.buildFromTemplate(template); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }); - }, - ); - - ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); - ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { - const externalUrl = getSafeExternalUrl(rawUrl); - if (!externalUrl) { - return false; - } - - try { - await shell.openExternal(externalUrl); - return true; - } catch { - return false; - } - }); - - ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); - ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); - - ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); - ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { - const result = await downloadAvailableUpdate(); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); - - ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); - ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { - if (isQuitting) { - return { - accepted: false, - completed: false, - state: updateState, - } satisfies DesktopUpdateActionResult; - } - const result = await installDownloadedUpdate(); + }), +); + +const resolveDesktopSshCliRunner = ( + environment: DesktopEnvironment.DesktopEnvironmentShape, + settings: DesktopSettingsValue, +): RemoteT3RunnerOptions => { + const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); + if (environment.isDevelopment && devRemoteEntryPath !== undefined) { return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); - - ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); - ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { - if (!updaterConfigured) { - return { - checked: false, - state: updateState, - } satisfies DesktopUpdateCheckResult; - } - const checked = await checkForUpdates("web-ui"); - return { - checked, - state: updateState, - } satisfies DesktopUpdateCheckResult; - }); -} - -function getIconOption(): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; - const iconPath = resolveIconPath(ext); - return iconPath ? { icon: iconPath } : {}; -} - -function getInitialWindowBackgroundColor(): string { - return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; -} - -function createWindow(): BrowserWindow { - const window = new BrowserWindow({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: isDevelopment, - autoHideMenuBar: true, - backgroundColor: getInitialWindowBackgroundColor(), - ...getIconOption(), - title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, - webPreferences: { - preload: Path.join(__dirname, "preload.js"), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - window.webContents.on("context-menu", (event, params) => { - event.preventDefault(); - - const menuTemplate: MenuItemConstructorOptions[] = []; - - if (params.misspelledWord) { - for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { - menuTemplate.push({ - label: suggestion, - click: () => window.webContents.replaceMisspelling(suggestion), - }); - } - if (params.dictionarySuggestions.length === 0) { - menuTemplate.push({ label: "No suggestions", enabled: false }); - } - menuTemplate.push({ type: "separator" }); - } - - const externalUrl = getSafeExternalUrl(params.linkURL); - if (externalUrl) { - menuTemplate.push( - { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) }, - { type: "separator" }, - ); - } - - if (params.mediaType === "image") { - menuTemplate.push({ - label: "Copy Image", - click: () => window.webContents.copyImageAt(params.x, params.y), - }); - menuTemplate.push({ type: "separator" }); - } - - menuTemplate.push( - { role: "cut", enabled: params.editFlags.canCut }, - { role: "copy", enabled: params.editFlags.canCopy }, - { role: "paste", enabled: params.editFlags.canPaste }, - { role: "selectAll", enabled: params.editFlags.canSelectAll }, - ); - - Menu.buildFromTemplate(menuTemplate).popup({ window }); - }); - - window.webContents.setWindowOpenHandler(({ url }) => { - const externalUrl = getSafeExternalUrl(url); - if (externalUrl) { - void shell.openExternal(externalUrl); - } - return { action: "deny" }; - }); - - window.on("page-title-updated", (event) => { - event.preventDefault(); - window.setTitle(APP_DISPLAY_NAME); - }); - window.webContents.on("did-finish-load", () => { - window.setTitle(APP_DISPLAY_NAME); - emitUpdateState(); - }); - if (!isDevelopment) { - window.once("ready-to-show", () => { - revealWindow(window); - }); - } - - if (isDevelopment) { - void window.loadURL(resolveDesktopDevServerUrl()); - window.webContents.openDevTools({ mode: "detach" }); - setImmediate(() => { - revealWindow(window); - }); - } else { - void window.loadURL(resolveDesktopWindowUrl()); - } - - window.on("closed", () => { - if (mainWindow === window) { - mainWindow = null; - } - }); - - return window; -} - -function resolveDesktopWindowUrl(): string { - if (backendHttpUrl) { - return backendHttpUrl; - } - - return `${DESKTOP_SCHEME}://app`; -} - -// Override Electron's userData path before the `ready` event so that -// Chromium session data uses a filesystem-friendly directory name. -// Must be called synchronously at the top level — before `app.whenReady()`. -app.setPath("userData", resolveUserDataPath()); - -configureAppIdentity(); - -async function bootstrap(): Promise { - writeDesktopLogHeader("bootstrap start"); - const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); - if (isDevelopment && configuredBackendPort === undefined) { - throw new Error("T3CODE_PORT is required in desktop development."); - } - - backendPort = - configuredBackendPort ?? - (await resolveDesktopBackendPort({ - host: DESKTOP_LOOPBACK_HOST, - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, - })); - writeDesktopLogHeader( - configuredBackendPort === undefined - ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` - : `using configured backend port port=${backendPort}`, - ); - backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); - if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { - writeDesktopLogHeader( - `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, - ); - } - const serverExposureState = await applyDesktopServerExposureMode( - desktopSettings.serverExposureMode, - { - persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, - }, - ); - writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); - if (serverExposureState.endpointUrl) { - writeDesktopLogHeader( - `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, - ); - } else if (desktopSettings.serverExposureMode === "network-accessible") { - writeDesktopLogHeader( - "bootstrap fell back to local-only because no advertised network host was available", - ); - } - - registerIpcHandlers(); - writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); - writeDesktopLogHeader("bootstrap backend start requested"); - - if (isDevelopment) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - void waitForBackendHttpReady(backendHttpUrl) - .then(() => { - writeDesktopLogHeader("bootstrap backend ready"); - }) - .catch((error) => { - if (isBackendReadinessAborted(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); - }); - return; + nodeScriptPath: devRemoteEntryPath, + nodeEngineRange: serverPackageJson.engines.node, + }; } + return { + packageSpec: resolveRemoteT3CliPackageSpec({ + appVersion: environment.appVersion, + updateChannel: settings.updateChannel, + isDevelopment: environment.isDevelopment, + }), + nodeEngineRange: serverPackageJson.engines.node, + }; +}; - await waitForBackendHttpReady(backendHttpUrl); - writeDesktopLogHeader("bootstrap backend ready"); - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); -} - -app.on("before-quit", () => { - isQuitting = true; - updateInstallInFlight = false; - writeDesktopLogHeader("before-quit received"); - clearUpdatePollTimer(); - cancelBackendReadinessWait(); - stopBackend(); - restoreStdIoCapture?.(); -}); - -app - .whenReady() - .then(() => { - writeDesktopLogHeader("app ready"); - configureAppIdentity(); - configureApplicationMenu(); - registerDesktopProtocol(); - configureAutoUpdater(); - void bootstrap().catch((error) => { - if (isBackendReadinessAborted(error) && isQuitting) { - return; - } - handleFatalStartupError("bootstrap", error); - }); - - app.on("activate", () => { - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; - if (existingWindow) { - revealWindow(existingWindow); - return; - } - mainWindow = createWindow(); +const desktopSshEnvironmentLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + return DesktopSshEnvironment.layer({ + resolveCliRunner: settings.get.pipe( + Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), + ), }); - }) - .catch((error) => { - handleFatalStartupError("whenReady", error); - }); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin" && !isQuitting) { - app.quit(); - } -}); - -if (process.platform !== "win32") { - process.on("SIGINT", () => { - if (isQuitting) return; - isQuitting = true; - writeDesktopLogHeader("SIGINT received"); - clearUpdatePollTimer(); - cancelBackendReadinessWait(); - stopBackend(); - restoreStdIoCapture?.(); - app.quit(); - }); - - process.on("SIGTERM", () => { - if (isQuitting) return; - isQuitting = true; - writeDesktopLogHeader("SIGTERM received"); - clearUpdatePollTimer(); - stopBackend(); - restoreStdIoCapture?.(); - app.quit(); - }); -} + }), +); + +const electronLayer = Layer.mergeAll( + ElectronApp.layer, + ElectronDialog.layer, + ElectronMenu.layer, + ElectronProtocol.layer, + DesktopSecretStorage.layer, + ElectronShell.layer, + ElectronTheme.layer, + ElectronUpdater.layer, + ElectronWindow.layer, + Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), +); + +const desktopFoundationLayer = Layer.mergeAll( + DesktopState.layer, + DesktopLifecycle.layerShutdown, + DesktopAppSettings.layer, + DesktopClientSettings.layer, + DesktopSavedEnvironments.layer, + DesktopAssets.layer, + DesktopObservability.layer, +).pipe(Layer.provideMerge(desktopEnvironmentLayer)); + +const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( + Layer.provideMerge(DesktopSshPasswordPrompts.layer()), +); + +const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), + Layer.provideMerge(desktopFoundationLayer), +); + +const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); + +const desktopBackendLayer = DesktopBackendManager.layer.pipe( + Layer.provideMerge(DesktopAppIdentity.layer), + Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(desktopWindowLayer), +); + +const desktopApplicationLayer = Layer.mergeAll( + DesktopLifecycle.layer, + DesktopApplicationMenu.layer, + DesktopShellEnvironment.layer, + desktopSshLayer, +).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); + +const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( + Layer.flatMap(() => + desktopApplicationLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(electronLayer), + ), + ), +); + +DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e3107a92485..173be8fb54a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,78 +1,127 @@ -import { contextBridge, ipcRenderer } from "electron"; import type { DesktopBridge } from "@t3tools/contracts"; +import { contextBridge, ipcRenderer } from "electron"; + +import * as IpcChannels from "./ipc/channels.ts"; -const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -const CONFIRM_CHANNEL = "desktop:confirm"; -const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; -const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const MENU_ACTION_CHANNEL = "desktop:menu-action"; -const UPDATE_STATE_CHANNEL = "desktop:update-state"; -const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; -const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; -const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; -const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; -const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; -const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; -const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +function unwrapEnsureSshEnvironmentResult(result: unknown) { + if ( + typeof result === "object" && + result !== null && + "type" in result && + result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT + ) { + const message = + "message" in result && typeof result.message === "string" + ? result.message + : "SSH authentication cancelled."; + throw new Error(message); + } + return result as Awaited>; +} contextBridge.exposeInMainWorld("desktopBridge", { + getAppBranding: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; + }, getLocalEnvironmentBootstrap: () => { - const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); if (typeof result !== "object" || result === null) { return null; } return result as ReturnType; }, - getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), - setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), - getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), + getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), + setClientSettings: (settings) => + ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), + getSavedEnvironmentRegistry: () => + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), setSavedEnvironmentRegistry: (records) => - ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), getSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), setSavedEnvironmentSecret: (environmentId, secret) => - ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), - getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), - setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), - pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), - confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), - setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), - openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), + ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), + ensureSshEnvironment: async (target, options) => + unwrapEnsureSshEnvironmentResult( + await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { + target, + ...(options === undefined ? {} : { options }), + }), + ), + disconnectSshEnvironment: (target) => + ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), + fetchSshEnvironmentDescriptor: (httpBaseUrl) => + ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }), + bootstrapSshBearerSession: (httpBaseUrl, credential) => + ipcRenderer.invoke(IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, { + httpBaseUrl, + credential, + }), + fetchSshSessionState: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), + issueSshWebSocketToken: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), + onSshPasswordPrompt: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + }; + }, + resolveSshPasswordPrompt: (requestId, password) => + ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), + getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), + setServerExposureMode: (mode) => + ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + setTailscaleServeEnabled: (input) => + ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), + getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), + setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), + showContextMenu: (items, position) => + ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { + items, + ...(position === undefined ? {} : { position }), + }), + openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; listener(action); }; - ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, - getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), - checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), - downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), - installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), + getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), + setUpdateChannel: (channel) => + ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), + checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), + downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), + installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; listener(state as Parameters[0]); }; - ipcRenderer.on(UPDATE_STATE_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/rotatingFileSink.test.ts b/apps/desktop/src/rotatingFileSink.test.ts deleted file mode 100644 index 53dd98ade8c..00000000000 --- a/apps/desktop/src/rotatingFileSink.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { afterEach, describe, expect, it } from "vitest"; - -const tempRoots: string[] = []; - -function makeTempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-")); - tempRoots.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempRoots.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("RotatingFileSink", () => { - it("rotates when writes exceed max bytes", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 10, - maxFiles: 3, - }); - - sink.write("12345"); - sink.write("67890"); - sink.write("abc"); - - expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc"); - expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890"); - }); - - it("retains only maxFiles backups", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "server-child.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 4, - maxFiles: 2, - }); - - sink.write("aaaa"); - sink.write("bbbb"); - sink.write("cccc"); - sink.write("dddd"); - - expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true); - expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true); - expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false); - }); - - it("prunes stale backups above maxFiles on startup", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first"); - fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale"); - - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 16, - maxFiles: 2, - }); - sink.write("hello"); - - expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false); - }); -}); diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts deleted file mode 100644 index 258a8fb2152..00000000000 --- a/apps/desktop/src/runtimeArch.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; - -describe("resolveDesktopRuntimeInfo", () => { - it("detects Rosetta-translated Intel builds on Apple Silicon", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", - processArch: "x64", - runningUnderArm64Translation: true, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "arm64", - appArch: "x64", - runningUnderArm64Translation: true, - }); - expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true); - }); - - it("detects native Apple Silicon builds", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", - processArch: "arm64", - runningUnderArm64Translation: false, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - }); - expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false); - }); - - it("passes through non-mac builds without translation", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "linux", - processArch: "x64", - runningUnderArm64Translation: true, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - }); - }); -}); diff --git a/apps/desktop/src/runtimeArch.ts b/apps/desktop/src/runtimeArch.ts deleted file mode 100644 index 127abf51ab8..00000000000 --- a/apps/desktop/src/runtimeArch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; - -interface ResolveDesktopRuntimeInfoInput { - readonly platform: NodeJS.Platform; - readonly processArch: string; - readonly runningUnderArm64Translation: boolean; -} - -function normalizeDesktopArch(arch: string): DesktopRuntimeArch { - if (arch === "arm64") return "arm64"; - if (arch === "x64") return "x64"; - return "other"; -} - -export function resolveDesktopRuntimeInfo( - input: ResolveDesktopRuntimeInfoInput, -): DesktopRuntimeInfo { - const appArch = normalizeDesktopArch(input.processArch); - - if (input.platform !== "darwin") { - return { - hostArch: appArch, - appArch, - runningUnderArm64Translation: false, - }; - } - - const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; - - return { - hostArch, - appArch, - runningUnderArm64Translation: input.runningUnderArm64Translation, - }; -} - -export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { - return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; -} diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts deleted file mode 100644 index b1ae4bef4f5..00000000000 --- a/apps/desktop/src/serverExposure.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; - -describe("resolveLanAdvertisedHost", () => { - it("prefers an explicit host override", () => { - expect( - resolveLanAdvertisedHost( - { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - "10.0.0.9", - ), - ).toBe("10.0.0.9"); - }); - - it("returns the first usable non-internal IPv4 address", () => { - expect( - resolveLanAdvertisedHost( - { - lo0: [ - { - address: "127.0.0.1", - family: "IPv4", - internal: true, - netmask: "255.0.0.0", - cidr: "127.0.0.1/8", - mac: "00:00:00:00:00:00", - }, - ], - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - undefined, - ), - ).toBe("192.168.1.44"); - }); - - it("returns null when no usable network address is available", () => { - expect( - resolveLanAdvertisedHost( - { - lo0: [ - { - address: "127.0.0.1", - family: "IPv4", - internal: true, - netmask: "255.0.0.0", - cidr: "127.0.0.1/8", - mac: "00:00:00:00:00:00", - }, - ], - }, - undefined, - ), - ).toBeNull(); - }); -}); - -describe("resolveDesktopServerExposure", () => { - it("keeps the desktop server loopback-only when local-only mode is selected", () => { - expect( - resolveDesktopServerExposure({ - mode: "local-only", - port: 3773, - networkInterfaces: {}, - }), - ).toEqual({ - mode: "local-only", - bindHost: "127.0.0.1", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: null, - advertisedHost: null, - }); - }); - - it("binds to all interfaces in network-accessible mode", () => { - expect( - resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - }), - ).toEqual({ - mode: "network-accessible", - bindHost: "0.0.0.0", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - }); - }); - - it("stays network-accessible even when no LAN address is currently detectable", () => { - expect( - resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: {}, - }), - ).toEqual({ - mode: "network-accessible", - bindHost: "0.0.0.0", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: null, - advertisedHost: null, - }); - }); -}); diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts deleted file mode 100644 index 65c99b60e13..00000000000 --- a/apps/desktop/src/serverExposure.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { NetworkInterfaceInfo } from "node:os"; -import type { DesktopServerExposureMode } from "@t3tools/contracts"; - -const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; - -export interface DesktopServerExposure { - readonly mode: DesktopServerExposureMode; - readonly bindHost: string; - readonly localHttpUrl: string; - readonly localWsUrl: string; - readonly endpointUrl: string | null; - readonly advertisedHost: string | null; -} - -const normalizeOptionalHost = (value: string | undefined): string | undefined => { - const normalized = value?.trim(); - return normalized && normalized.length > 0 ? normalized : undefined; -}; - -const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); - -export function resolveLanAdvertisedHost( - networkInterfaces: NodeJS.Dict, - explicitHost: string | undefined, -): string | null { - const normalizedExplicitHost = normalizeOptionalHost(explicitHost); - if (normalizedExplicitHost) { - return normalizedExplicitHost; - } - - for (const interfaceAddresses of Object.values(networkInterfaces)) { - if (!interfaceAddresses) continue; - - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isUsableLanIpv4Address(address.address)) continue; - return address.address; - } - } - - return null; -} - -export function resolveDesktopServerExposure(input: { - readonly mode: DesktopServerExposureMode; - readonly port: number; - readonly networkInterfaces: NodeJS.Dict; - readonly advertisedHostOverride?: string; -}): DesktopServerExposure { - const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; - const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; - - if (input.mode === "local-only") { - return { - mode: input.mode, - bindHost: DESKTOP_LOOPBACK_HOST, - localHttpUrl, - localWsUrl, - endpointUrl: null, - advertisedHost: null, - }; - } - - const advertisedHost = resolveLanAdvertisedHost( - input.networkInterfaces, - input.advertisedHostOverride, - ); - - return { - mode: input.mode, - bindHost: DESKTOP_LAN_BIND_HOST, - localHttpUrl, - localWsUrl, - endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, - advertisedHost, - }; -} diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts new file mode 100644 index 00000000000..db6194cf8f7 --- /dev/null +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -0,0 +1,284 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + DEFAULT_DESKTOP_SETTINGS, + resolveDefaultDesktopSettings, + type DesktopSettings as DesktopSettingsValue, +} from "./DesktopAppSettings.ts"; +import * as DesktopAppSettings from "./DesktopAppSettings.ts"; + +const DesktopSettingsPatch = Schema.Struct({ + serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), + tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), + tailscaleServePort: Schema.optionalKey(Schema.Number), + updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), + updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), +}); + +const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); +const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); + +function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion, + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); +} + +const withSettings = ( + effect: Effect.Effect< + A, + E, + R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment + >, + options?: { readonly appVersion?: string }, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-settings-test-", + }); + return yield* effect.pipe( + Effect.provide( + DesktopAppSettings.layer.pipe( + Layer.provideMerge(makeEnvironmentLayer(baseDir, options?.appVersion)), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const encoded = yield* encodeDesktopSettingsPatch(patch); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`); + }); +} + +describe("DesktopSettings", () => { + it.effect("loads defaults when no settings file exists", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS); + }), + ), + ); + + it("defaults packaged nightly builds to the nightly update channel", () => { + assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }); + + it.effect("loads persisted settings and applies semantic updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + } satisfies DesktopSettingsValue); + + const exposure = yield* settings.setServerExposureMode("local-only"); + assert.isTrue(exposure.changed); + assert.equal(exposure.settings.serverExposureMode, "local-only"); + + const tailscale = yield* settings.setTailscaleServe({ + enabled: true, + port: Option.some(9443), + }); + assert.isTrue(tailscale.changed); + assert.equal(tailscale.settings.tailscaleServePort, 9443); + + const updateChannel = yield* settings.setUpdateChannel("nightly"); + assert.isTrue(updateChannel.changed); + assert.equal(updateChannel.settings.updateChannel, "nightly"); + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + }), + ), + ); + + it.effect("does not persist no-op semantic updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + const exposure = yield* settings.setServerExposureMode("local-only"); + assert.isFalse(exposure.changed); + + const tailscale = yield* settings.setTailscaleServe({ + enabled: false, + port: Option.none(), + }); + assert.isFalse(tailscale.changed); + + const updateChannel = yield* settings.setUpdateChannel("latest"); + assert.isFalse(updateChannel.changed); + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); + }), + ), + ); + + it.effect("falls back to defaults when the settings file is malformed", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json"); + + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + }), + ), + ); + + it.effect("loads lenient persisted desktop settings JSON", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ + // JSONC-style comments and trailing commas match server settings parsing. + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + }\n`, + ); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + ), + ); + + it.effect("persists sparse desktop settings documents", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + ); + assert.deepEqual(persisted, { + serverExposureMode: "network-accessible", + } satisfies typeof DesktopSettingsPatch.Type); + }), + ), + ); + + it.effect("migrates legacy implicit update channels to the runtime default", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "local-only", + updateChannel: "latest", + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + { appVersion: "0.0.17-nightly.20260415.1" }, + ), + ); + + it.effect("preserves explicit stable update channel on nightly builds", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + } satisfies DesktopSettingsValue); + }), + { appVersion: "0.0.17-nightly.20260415.1" }, + ), + ); + + it.effect("normalizes invalid persisted Tailscale Serve ports", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + tailscaleServeEnabled: true, + tailscaleServePort: 0, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts new file mode 100644 index 00000000000..177f05a4b2b --- /dev/null +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -0,0 +1,318 @@ +import { + DesktopServerExposureModeSchema, + DesktopUpdateChannelSchema, + type DesktopServerExposureMode, + type DesktopUpdateChannel, +} from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 PlatformError from "effect/PlatformError"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; + +export interface DesktopSettings { + readonly serverExposureMode: DesktopServerExposureMode; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; + readonly updateChannel: DesktopUpdateChannel; + readonly updateChannelConfiguredByUser: boolean; +} + +export interface DesktopSettingsChange { + readonly settings: DesktopSettings; + readonly changed: boolean; +} + +export const DEFAULT_TAILSCALE_SERVE_PORT = 443; + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + updateChannel: "latest", + updateChannelConfiguredByUser: false, +}; + +const DesktopSettingsDocument = Schema.Struct({ + serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), + tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), + tailscaleServePort: Schema.optionalKey(Schema.Number), + updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), + updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), +}); + +type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; +type Mutable = { -readonly [K in keyof T]: T[K] }; + +const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); +const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); +const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); + +const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ + settings, + changed, +}); + +export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop settings: ${this.cause.message}`; + } +} + +export interface DesktopAppSettingsShape { + readonly load: Effect.Effect; + readonly get: Effect.Effect; + readonly setServerExposureMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServe: (input: { + readonly enabled: boolean; + readonly port: Option.Option; + }) => Effect.Effect; + readonly setUpdateChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; +} + +export class DesktopAppSettings extends Context.Service< + DesktopAppSettings, + DesktopAppSettingsShape +>()("t3/desktop/AppSettings") {} + +export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { + return { + ...DEFAULT_DESKTOP_SETTINGS, + updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + }; +} + +function normalizeTailscaleServePort(value: unknown): number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 + ? value + : DEFAULT_TAILSCALE_SERVE_PORT; +} + +function normalizeDesktopSettingsDocument( + parsed: DesktopSettingsDocument, + appVersion: string, +): DesktopSettings { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; + const updateChannelConfiguredByUser = + parsed.updateChannelConfiguredByUser === true || + (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); + + return { + serverExposureMode: + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, + tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), + updateChannel: updateChannelConfiguredByUser + ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) + : defaultSettings.updateChannel, + updateChannelConfiguredByUser, + }; +} + +function toDesktopSettingsDocument( + settings: DesktopSettings, + defaults: DesktopSettings, +): DesktopSettingsDocument { + const document: Mutable = {}; + + if (settings.serverExposureMode !== defaults.serverExposureMode) { + document.serverExposureMode = settings.serverExposureMode; + } + if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) { + document.tailscaleServeEnabled = settings.tailscaleServeEnabled; + } + if (settings.tailscaleServePort !== defaults.tailscaleServePort) { + document.tailscaleServePort = settings.tailscaleServePort; + } + if (settings.updateChannel !== defaults.updateChannel) { + document.updateChannel = settings.updateChannel; + } + if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { + document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; + } + + return document; +} + +function setServerExposureMode( + settings: DesktopSettings, + requestedMode: DesktopServerExposureMode, +): DesktopSettings { + return settings.serverExposureMode === requestedMode + ? settings + : { + ...settings, + serverExposureMode: requestedMode, + }; +} + +function setTailscaleServe( + settings: DesktopSettings, + input: { readonly enabled: boolean; readonly port: Option.Option }, +): DesktopSettings { + const port = Option.match(input.port, { + onNone: () => settings.tailscaleServePort, + onSome: normalizeTailscaleServePort, + }); + return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port + ? settings + : { + ...settings, + tailscaleServeEnabled: input.enabled, + tailscaleServePort: port, + }; +} + +function setUpdateChannel( + settings: DesktopSettings, + requestedChannel: DesktopUpdateChannel, +): DesktopSettings { + return settings.updateChannel === requestedChannel + ? settings + : { + ...settings, + updateChannel: requestedChannel, + updateChannelConfiguredByUser: true, + }; +} + +function readSettings( + fileSystem: FileSystem.FileSystem, + settingsPath: string, + appVersion: string, +): Effect.Effect { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + + return fileSystem.readFileString(settingsPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(defaultSettings), + onSome: (raw) => + decodeDesktopSettingsJson(raw).pipe( + Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), + Effect.catch(() => Effect.succeed(defaultSettings)), + ), + }), + ), + ); +} + +const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly settingsPath: string; + readonly settings: DesktopSettings; + readonly defaultSettings: DesktopSettings; +}): Effect.fn.Return { + const directory = input.path.dirname(input.settingsPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeDesktopSettingsJson( + toDesktopSettingsDocument(input.settings, input.defaultSettings), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.settingsPath); +}); + +export const layer = Layer.effect( + DesktopAppSettings, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); + + const persist = ( + update: (settings: DesktopSettings) => DesktopSettings, + ): Effect.Effect => + SynchronizedRef.modifyEffect(settingsRef, (settings) => { + const nextSettings = update(settings); + if (nextSettings === settings) { + return Effect.succeed([settingsChange(settings, false), settings] as const); + } + + return writeSettings({ + fileSystem, + path, + settingsPath: environment.desktopSettingsPath, + settings: nextSettings, + defaultSettings: environment.defaultDesktopSettings, + }).pipe( + Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })), + Effect.as([settingsChange(nextSettings, true), nextSettings] as const), + ); + }); + + return DesktopAppSettings.of({ + get: SynchronizedRef.get(settingsRef), + load: Effect.gen(function* () { + const settings = yield* readSettings( + fileSystem, + environment.desktopSettingsPath, + environment.appVersion, + ); + return yield* SynchronizedRef.setAndGet(settingsRef, settings); + }).pipe(Effect.withSpan("desktop.settings.load")), + setServerExposureMode: (mode) => + persist((settings) => setServerExposureMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), + ), + setTailscaleServe: (input) => + persist((settings) => setTailscaleServe(settings, input)).pipe( + Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), + ), + setUpdateChannel: (channel) => + persist((settings) => setUpdateChannel(settings, channel)).pipe( + Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), + ), + }); + }), +); + +export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => + Layer.effect( + DesktopAppSettings, + Effect.gen(function* () { + const settingsRef = yield* SynchronizedRef.make(initialSettings); + const update = (f: (settings: DesktopSettings) => DesktopSettings) => + SynchronizedRef.modify(settingsRef, (settings) => { + const nextSettings = f(settings); + return [ + { + settings: nextSettings, + changed: nextSettings !== settings, + }, + nextSettings, + ] as const; + }); + + return DesktopAppSettings.of({ + get: SynchronizedRef.get(settingsRef), + load: SynchronizedRef.get(settingsRef), + setServerExposureMode: (mode) => + update((settings) => setServerExposureMode(settings, mode)), + setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), + setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), + }); + }), + ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts new file mode 100644 index 00000000000..f666e692860 --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -0,0 +1,185 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; + +const clientSettings: ClientSettings = { + autoOpenPlanSidebar: false, + confirmThreadArchive: true, + confirmThreadDelete: false, + dismissedProviderUpdateNotificationKeys: [], + diffIgnoreWhitespace: true, + diffWordWrap: true, + favorites: [], + providerModelPreferences: {}, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, + timestampFormat: "24-hour", +}; + +const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); +const decodeRecordJson = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +function makeLayer(baseDir: string) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopClientSettings.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withClientSettings = ( + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-client-settings-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopClientSettings", () => { + it.effect("returns none when no client settings file exists", () => + withClientSettings( + Effect.gen(function* () { + const settings = yield* DesktopClientSettings.DesktopClientSettings; + assert.isTrue(Option.isNone(yield* settings.get)); + }), + ), + ); + + it.effect("persists and reloads client settings", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* settings.set(clientSettings); + + assert.deepEqual(yield* settings.get, Option.some(clientSettings)); + assert.deepEqual( + yield* decodeClientSettingsJson( + yield* fileSystem.readFileString(environment.clientSettingsPath), + ), + clientSettings, + ); + assert.isFalse( + Object.hasOwn( + yield* decodeRecordJson( + yield* fileSystem.readFileString(environment.clientSettingsPath), + ), + "settings", + ), + ); + }), + ), + ); + + it.effect("loads lenient direct client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + `{ + // Matches server settings parsing. + "timestampFormat": "24-hour", + }\n`, + ); + + const persisted = yield* settings.get; + assert.isTrue(Option.isSome(persisted)); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.timestampFormat, "24-hour"); + } + }), + ), + ); + + it.effect("loads legacy wrapped client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + `{ + "settings": { + "timestampFormat": "12-hour" + } + }\n`, + ); + + const persisted = yield* settings.get; + assert.isTrue(Option.isSome(persisted)); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.timestampFormat, "12-hour"); + } + }), + ), + ); + + it.effect("loads defaults from empty client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n"); + + assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}"))); + }), + ), + ); + + it.effect("treats malformed client settings documents as absent", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + + assert.isTrue(Option.isNone(yield* settings.get)); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts new file mode 100644 index 00000000000..2153125e8e7 --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -0,0 +1,122 @@ +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 PlatformError from "effect/PlatformError"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Ref from "effect/Ref"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const ClientSettingsDocumentSchema = Schema.Struct({ + settings: ClientSettingsSchema, +}); + +const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); +const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); +const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( + LegacyClientSettingsDocumentJson, +); +const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); +const decodeClientSettingsJson = (raw: string): Effect.Effect => + decodeLegacyClientSettingsDocumentJson(raw).pipe( + Effect.map((document) => document.settings), + Effect.catch(() => decodeClientSettingsJsonValue(raw)), + ); +const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); + +export class DesktopClientSettingsWriteError extends Data.TaggedError( + "DesktopClientSettingsWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop client settings: ${this.cause.message}`; + } +} + +export interface DesktopClientSettingsShape { + readonly get: Effect.Effect>; + readonly set: (settings: ClientSettings) => Effect.Effect; +} + +export class DesktopClientSettings extends Context.Service< + DesktopClientSettings, + DesktopClientSettingsShape +>()("t3/desktop/ClientSettings") {} + +const readClientSettings = ( + fileSystem: FileSystem.FileSystem, + settingsPath: string, +): Effect.Effect> => + fileSystem.readFileString(settingsPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => + decodeClientSettingsJson(raw).pipe( + Effect.map((settings) => Option.some(settings)), + Effect.catch(() => Effect.succeed(Option.none())), + ), + }), + ), + ); + +const writeClientSettings = Effect.fnUntraced(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly settingsPath: string; + readonly settings: ClientSettings; +}): Effect.fn.Return { + const directory = input.path.dirname(input.settingsPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeClientSettingsJson(input.settings); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.settingsPath); +}); + +export const layer = Layer.effect( + DesktopClientSettings, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return DesktopClientSettings.of({ + get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( + Effect.withSpan("desktop.clientSettings.get"), + ), + set: (settings) => + writeClientSettings({ + fileSystem, + path, + settingsPath: environment.clientSettingsPath, + settings, + }).pipe( + Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), + Effect.withSpan("desktop.clientSettings.set"), + ), + }); + }), +); + +export const layerTest = (initialSettings: Option.Option = Option.none()) => + Layer.effect( + DesktopClientSettings, + Effect.gen(function* () { + const settingsRef = yield* Ref.make(initialSettings); + return DesktopClientSettings.of({ + get: Ref.get(settingsRef), + set: (settings) => Ref.set(settingsRef, Option.some(settings)), + }); + }), + ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts new file mode 100644 index 00000000000..d1d37b96e11 --- /dev/null +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -0,0 +1,344 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: "2026-04-09T01:00:00.000Z", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, +}; + +const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({ + version: Schema.Number, + records: Schema.Array(Schema.Unknown), +}); +const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( + Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe), +); + +function makeSafeStorageLayer(input: { + readonly available: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; +}) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: + input.availabilityError === undefined + ? Effect.succeed(input.available) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ + cause: input.availabilityError, + }), + ), + encryptString: (value) => + input.encryptError === undefined + ? Effect.succeed(textEncoder.encode(`enc:${value}`)) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageEncryptError({ + cause: input.encryptError, + }), + ), + decryptString: (value) => { + if (input.decryptError !== undefined) { + return Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: input.decryptError, + }), + ); + } + + const decoded = textDecoder.decode(value); + if (!decoded.startsWith("enc:")) { + return Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("invalid secret"), + }), + ); + } + return Effect.succeed(decoded.slice("enc:".length)); + }, + } satisfies ElectronSafeStorage.ElectronSafeStorageShape); +} + +function makeLayer( + baseDir: string, + options?: { + readonly availableSecretStorage?: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; + }, +) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopSavedEnvironments.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge( + makeSafeStorageLayer({ + available: options?.availableSecretStorage ?? true, + availabilityError: options?.availabilityError, + encryptError: options?.encryptError, + decryptError: options?.decryptError, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withSavedEnvironments = ( + effect: Effect.Effect, + options?: { + readonly availableSecretStorage?: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; + }, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir, options))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopSavedEnvironments", () => { + it.effect("persists and reloads saved environment metadata", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe( + yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath), + ); + assert.equal(persisted.version, 1); + assert.lengthOf(persisted.records, 1); + }), + ), + ); + + it.effect("loads lenient saved environment registry documents", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.savedEnvironmentRegistryPath, + `{ + // Same optional envelope shape as browser saved environments. + "version": 1, + "records": [ + { + "environmentId": "${savedRegistryRecord.environmentId}", + "label": "Remote environment", + "httpBaseUrl": "https://remote.example.com/", + "wsBaseUrl": "wss://remote.example.com/", + "createdAt": "2026-04-09T00:00:00.000Z", + "lastConnectedAt": "2026-04-09T01:00:00.000Z", + "desktopSsh": { + "alias": "devbox", + "hostname": "devbox.example.com", + "username": "julius", + "port": 22, + }, + }, + ], + }\n`, + ); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + }), + ), + ); + + it.effect("persists encrypted saved environment secrets when encryption is available", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.isTrue( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }), + ); + + assert.deepEqual( + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), + Option.some("bearer-token"), + ); + }), + ), + ); + + it.effect("returns false when writing secrets while encryption is unavailable", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.isFalse( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + }), + ); + }), + { availableSecretStorage: false }, + ), + ); + + it.effect("surfaces typed safe storage availability failures", () => { + const cause = new Error("safe storage unavailable"); + return withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + const error = yield* savedEnvironments + .setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + assert.equal(error.cause, cause); + }), + { availabilityError: cause }, + ); + }); + + it.effect("removes saved environment secrets", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId); + + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("treats empty saved environment documents as empty", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n"); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("treats malformed saved environment documents as empty", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("returns false when writing a secret without metadata", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + + assert.isFalse( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }), + ); + }), + ), + ); + + it.effect("preserves encrypted secrets when metadata is rewritten", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + assert.deepEqual( + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), + Option.some("bearer-token"), + ); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts new file mode 100644 index 00000000000..ec36aa4f6ef --- /dev/null +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -0,0 +1,390 @@ +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +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 PlatformError from "effect/PlatformError"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Ref from "effect/Ref"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; + +type PersistedSavedEnvironmentDesktopSsh = NonNullable< + PersistedSavedEnvironmentRecord["desktopSsh"] +>; + +interface PersistedSavedEnvironmentStorageRecord extends Omit< + PersistedSavedEnvironmentRecord, + "desktopSsh" +> { + readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh; + readonly encryptedBearerToken?: string; +} + +interface SavedEnvironmentRegistryDocument { + readonly version: number; + readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +interface SavedEnvironmentRegistryStorageDocument { + readonly version?: number; + readonly records?: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +const DesktopSshTargetSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), +}); + +const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), + encryptedBearerToken: Schema.optionalKey(Schema.String), +}); + +const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), + records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)), +}); + +const SavedEnvironmentRegistryDocumentJson = fromLenientJson( + SavedEnvironmentRegistryDocumentSchema, +); +const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( + SavedEnvironmentRegistryDocumentJson, +); +const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( + SavedEnvironmentRegistryDocumentJson, +); + +export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( + "DesktopSavedEnvironmentsWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop saved environments: ${this.cause.message}`; + } +} + +export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( + "DesktopSavedEnvironmentSecretDecodeError", +)<{ + readonly cause: Encoding.EncodingError; +}> { + override get message() { + return "Failed to decode desktop saved environment secret."; + } +} + +export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentSecretDecodeError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError; + +export type DesktopSavedEnvironmentsSetSecretError = + | DesktopSavedEnvironmentsWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError; + +export interface DesktopSavedEnvironmentsShape { + readonly getRegistry: Effect.Effect; + readonly setRegistry: ( + records: readonly PersistedSavedEnvironmentRecord[], + ) => Effect.Effect; + readonly getSecret: ( + environmentId: string, + ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; + readonly setSecret: (input: { + readonly environmentId: string; + readonly secret: string; + }) => Effect.Effect; + readonly removeSecret: ( + environmentId: string, + ) => Effect.Effect; +} + +export class DesktopSavedEnvironments extends Context.Service< + DesktopSavedEnvironments, + DesktopSavedEnvironmentsShape +>()("t3/desktop/SavedEnvironments") {} + +function toPersistedSavedEnvironmentRecord( + record: PersistedSavedEnvironmentStorageRecord, +): PersistedSavedEnvironmentRecord { + const nextRecord = { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; +} + +function toSavedEnvironmentStorageRecord( + record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord, + encryptedBearerToken: Option.Option, +): PersistedSavedEnvironmentStorageRecord { + const nextRecord = { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; + const desktopSsh = record.desktopSsh; + if (desktopSsh) { + return Option.match(encryptedBearerToken, { + onNone: () => ({ ...nextRecord, desktopSsh }), + onSome: (value) => ({ + ...nextRecord, + desktopSsh, + encryptedBearerToken: value, + }), + }); + } + return Option.match(encryptedBearerToken, { + onNone: () => nextRecord, + onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }), + }); +} + +function normalizeSavedEnvironmentRegistryDocument( + document: SavedEnvironmentRegistryStorageDocument, +): SavedEnvironmentRegistryDocument { + return { + version: document.version ?? 1, + records: document.records ?? [], + }; +} + +function readRegistryDocument( + fileSystem: FileSystem.FileSystem, + registryPath: string, +): Effect.Effect { + return fileSystem.readFileString(registryPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed({ version: 1, records: [] }), + onSome: (raw) => + decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( + Effect.map(normalizeSavedEnvironmentRegistryDocument), + Effect.catch(() => Effect.succeed({ version: 1, records: [] })), + ), + }), + ), + ); +} + +const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly registryPath: string; + readonly document: SavedEnvironmentRegistryDocument; + }): Effect.fn.Return { + const directory = input.path.dirname(input.registryPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.registryPath); + }, +); + +function preserveExistingSecrets( + currentDocument: SavedEnvironmentRegistryDocument, + records: readonly PersistedSavedEnvironmentRecord[], +): SavedEnvironmentRegistryDocument { + const encryptedBearerTokenById = new Map( + currentDocument.records.flatMap((record) => + record.encryptedBearerToken + ? [[record.environmentId, record.encryptedBearerToken] as const] + : [], + ), + ); + + return { + version: currentDocument.version, + records: records.map((record) => { + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); + return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken)); + }), + }; +} + +function decodeSecretBytes( + encoded: string, +): Effect.Effect { + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( + Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })), + ); +} + +export const layer = Layer.effect( + DesktopSavedEnvironments, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + + const writeDocument = (document: SavedEnvironmentRegistryDocument) => + writeRegistryDocument({ + fileSystem, + path, + registryPath: environment.savedEnvironmentRegistryPath, + document, + }).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); + + return DesktopSavedEnvironments.of({ + getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( + Effect.map((document) => + document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), + ), + Effect.withSpan("desktop.savedEnvironments.getRegistry"), + ), + setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { + const currentDocument = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + yield* writeDocument(preserveExistingSecrets(currentDocument, records)); + }), + getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + const encoded = Option.fromNullishOr( + document.records.find((record) => record.environmentId === environmentId) + ?.encryptedBearerToken, + ); + if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes(encoded.value); + return Option.some(yield* safeStorage.decryptString(secretBytes)); + }), + setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { + const { environmentId, secret } = input; + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret), + ); + let found = false; + const nextDocument: SavedEnvironmentRegistryDocument = { + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + + found = true; + return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); + }), + }; + + if (found) { + yield* writeDocument(nextDocument); + } + return found; + }), + removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + if ( + !document.records.some( + (record) => + record.environmentId === environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); + }), + }); + }), +); + +export const layerTest = (input?: { + readonly records?: readonly PersistedSavedEnvironmentRecord[]; + readonly secrets?: ReadonlyMap; +}) => + Layer.effect( + DesktopSavedEnvironments, + Effect.gen(function* () { + const recordsRef = yield* Ref.make(input?.records ?? []); + const secretsRef = yield* Ref.make(new Map(input?.secrets ?? [])); + + return DesktopSavedEnvironments.of({ + getRegistry: Ref.get(recordsRef), + setRegistry: (records) => Ref.set(recordsRef, records), + getSecret: (environmentId) => + Ref.get(secretsRef).pipe( + Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))), + ), + setSecret: ({ environmentId, secret }) => + Ref.get(recordsRef).pipe( + Effect.flatMap((records) => { + if (!records.some((record) => record.environmentId === environmentId)) { + return Effect.succeed(false); + } + return Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.set(environmentId, secret); + return nextSecrets; + }).pipe(Effect.as(true)); + }), + ), + removeSecret: (environmentId) => + Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.delete(environmentId); + return nextSecrets; + }), + }); + }), + ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts new file mode 100644 index 00000000000..897e7336a24 --- /dev/null +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -0,0 +1,232 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; + +const textEncoder = new TextEncoder(); + +function envOutput(values: Readonly>): string { + return Object.entries(values) + .flatMap(([name, value]) => [ + `__T3CODE_ENV_${name}_START__`, + value, + `__T3CODE_ENV_${name}_END__`, + ]) + .join("\n"); +} + +function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle { + const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +function withProcessEnv( + env: NodeJS.ProcessEnv, + effect: Effect.Effect, +): Effect.Effect { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env; + process.env = env; + return previous; + }), + () => effect, + (previous) => + Effect.sync(() => { + process.env = previous; + }), + ); +} + +function runShellEnvironment(input: { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly handler: (command: ChildProcess.Command) => string; +}) { + const environmentLayer = Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: input.platform, + } as DesktopEnvironment.DesktopEnvironmentShape), + ); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), + ); + + const program = Effect.gen(function* () { + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + yield* shellEnvironment.installIntoProcess; + }).pipe( + Effect.provide( + DesktopShellEnvironment.layer.pipe( + Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + ), + ), + ); + + return withProcessEnv(input.env, program); +} + +describe("DesktopShellEnvironment", () => { + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/Users/test/.local/bin:/usr/bin", + }; + const commands: ChildProcess.Command[] = []; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: (command) => { + commands.push(command); + return envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", + }); + }, + }); + + assert.equal(commands.length, 1); + assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh"); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); + assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + }), + ); + + it.effect("preserves inherited POSIX values when present", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/login-shell.sock", + }), + }); + + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); + }), + ); + + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }), + }); + + assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); + }), + ); + + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const commands: string[] = []; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + commands.push(command.command); + return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : ""; + }, + }); + + assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + }), + ); + + it.effect("loads PowerShell profile environment on Windows", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + + yield* runShellEnvironment({ + env, + platform: "win32", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + const loadProfile = !command.args.includes("-NoProfile"); + return loadProfile + ? envOutput({ + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }) + : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }); + }, + }); + + assert.equal( + env.PATH, + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + assert.equal( + env.FNM_MULTISHELL_PATH, + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + }), + ); +}); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts new file mode 100644 index 00000000000..358729e05ef --- /dev/null +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -0,0 +1,356 @@ +import * as Context from "effect/Context"; +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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +type EnvironmentPatch = Record; + +interface ShellEnvironmentConfig { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly userShell: Option.Option; +} + +interface WindowsProbeOptions { + readonly loadProfile: boolean; +} + +export interface DesktopShellEnvironmentShape { + readonly installIntoProcess: Effect.Effect; +} + +export class DesktopShellEnvironment extends Context.Service< + DesktopShellEnvironment, + DesktopShellEnvironmentShape +>()("t3/desktop/ShellEnvironment") {} + +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; +const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); +const LAUNCHCTL_TIMEOUT = Duration.seconds(2); +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); + +const trimNonEmpty = (value: string | null | undefined): Option.Option => + Option.fromNullishOr(value).pipe( + Option.map((entry) => entry.trim()), + Option.filter((entry) => entry.length > 0), + ); + +const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); + +const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => + trimNonEmpty(env.PATH ?? env.Path ?? env.path); + +const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { + const normalized = entry.trim().replace(/^"+|"+$/g, ""); + return platform === "win32" ? normalized.toLowerCase() : normalized; +}; + +const mergePaths = ( + platform: NodeJS.Platform, + values: ReadonlyArray>, +): Option.Option => { + const delimiter = pathDelimiter(platform); + const entries: string[] = []; + const seen = new Set(); + + for (const value of values) { + if (Option.isNone(value)) continue; + + for (const entry of value.value.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const key = pathComparisonKey(trimmed, platform); + if (key.length === 0 || seen.has(key)) continue; + + seen.add(key); + entries.push(trimmed); + } + } + + return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); +}; + +const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray => { + const fallback = + config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : ""; + const seen = new Set(); + const candidates: string[] = []; + + for (const candidate of [ + trimNonEmpty(config.env.SHELL), + config.userShell, + trimNonEmpty(fallback), + ]) { + if (Option.isNone(candidate) || seen.has(candidate.value)) continue; + seen.add(candidate.value); + candidates.push(candidate.value); + } + + return candidates; +}; + +const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ + ...trimNonEmpty(env.APPDATA).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\npm`], + }), + ), + ...trimNonEmpty(env.LOCALAPPDATA).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`], + }), + ), + ...trimNonEmpty(env.USERPROFILE).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`], + }), + ), +]; + +const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; +const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; + +const capturePosixEnvironmentCommand = (names: ReadonlyArray) => + names + .map((name) => { + return [ + `printf '%s\\n' '${startMarker(name)}'`, + `printenv ${name} || true`, + `printf '%s\\n' '${endMarker(name)}'`, + ].join("; "); + }) + .join("; "); + +const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => + [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + return [ + `Write-Output '${startMarker(name)}'`, + `$value = [Environment]::GetEnvironmentVariable('${name}')`, + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${endMarker(name)}'`, + ]; + }), + ].join("; "); + +const extractEnvironment = (output: string, names: ReadonlyArray): EnvironmentPatch => { + const environment: EnvironmentPatch = {}; + + for (const name of names) { + const start = output.indexOf(startMarker(name)); + if (start === -1) continue; + + const valueStart = start + startMarker(name).length; + const end = output.indexOf(endMarker(name), valueStart); + if (end === -1) continue; + + const value = output + .slice(valueStart, end) + .replace(/^\r?\n/, "") + .replace(/\r?\n$/, ""); + if (value.length > 0) { + environment[name] = value; + } + } + + return environment; +}; + +const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly timeout: Duration.Duration; + readonly shell?: boolean; +}): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string( + ChildProcess.make(input.command, input.args, { + shell: input.shell ?? false, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }), + ) + .pipe( + Effect.timeoutOption(input.timeout), + Effect.map(Option.getOrElse(() => "")), + Effect.catch(() => Effect.succeed("")), + ); +}); + +const readLoginShellEnvironment = ( + shell: string, + names: ReadonlyArray, +): Effect.Effect => + names.length === 0 + ? Effect.succeed({}) + : runCommandOutput({ + command: shell, + args: ["-ilc", capturePosixEnvironmentCommand(names)], + timeout: LOGIN_SHELL_TIMEOUT, + }).pipe(Effect.map((output) => extractEnvironment(output, names))); + +const readLaunchctlPath: Effect.Effect< + Option.Option, + never, + ChildProcessSpawner.ChildProcessSpawner +> = runCommandOutput({ + command: "/bin/launchctl", + args: ["getenv", "PATH"], + timeout: LAUNCHCTL_TIMEOUT, +}).pipe(Effect.map(trimNonEmpty)); + +const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( + function* ( + names: ReadonlyArray, + options: WindowsProbeOptions, + ): Effect.fn.Return { + if (names.length === 0) return {}; + + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + captureWindowsEnvironmentCommand(names), + ]; + + for (const command of WINDOWS_SHELL_CANDIDATES) { + const output = yield* runCommandOutput({ + command, + args, + shell: true, + timeout: LOGIN_SHELL_TIMEOUT, + }); + const environment = extractEnvironment(output, names); + if (Object.keys(environment).length > 0) { + return environment; + } + } + + return {}; + }, +); + +const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( + function* ( + config: ShellEnvironmentConfig, + ): Effect.fn.Return { + const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false }); + const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { + loadProfile: true, + }); + const mergedPath = mergePaths("win32", [ + trimNonEmpty(profile.PATH), + trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), + trimNonEmpty(noProfile.PATH), + readEnvPath(config.env), + ]); + + if (Option.isSome(mergedPath)) { + config.env.PATH = mergedPath.value; + } + if (!config.env.FNM_DIR && profile.FNM_DIR) { + config.env.FNM_DIR = profile.FNM_DIR; + } + if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { + config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; + } + }, +); + +const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( + function* ( + config: ShellEnvironmentConfig, + ): Effect.fn.Return { + const shellEnvironment: EnvironmentPatch = {}; + + for (const shell of listLoginShellCandidates(config)) { + Object.assign( + shellEnvironment, + yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), + ); + if (shellEnvironment.PATH) break; + } + + const launchctlPath = + config.platform === "darwin" && !shellEnvironment.PATH + ? yield* readLaunchctlPath + : Option.none(); + const mergedPath = mergePaths(config.platform, [ + trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), + readEnvPath(config.env), + ]); + + if (Option.isSome(mergedPath)) { + config.env.PATH = mergedPath.value; + } + if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { + config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + } + + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] as const) { + if (!config.env[name] && shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } + }, +); + +const installShellEnvironment = ( + config: ShellEnvironmentConfig, +): Effect.Effect => { + if (config.platform === "win32") { + return installWindowsEnvironment(config); + } + if (config.platform === "darwin" || config.platform === "linux") { + return installPosixEnvironment(config); + } + return Effect.void; +}; + +export const layer = Layer.effect( + DesktopShellEnvironment, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return DesktopShellEnvironment.of({ + installIntoProcess: installShellEnvironment({ + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ), + }); + }), +); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts new file mode 100644 index 00000000000..77c86be39d2 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -0,0 +1,118 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as NetService from "@t3tools/shared/Net"; +import { SshPasswordPromptError } from "@t3tools/ssh/errors"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +function makeTempHomeDir() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" }); + }); +} + +describe("sshEnvironment", () => { + it("treats password prompt timeouts as cancellable authentication prompts", () => { + assert.equal( + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( + new SshPasswordPromptError({ + message: "SSH authentication timed out for devbox.", + cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ + requestId: "prompt-1", + destination: "devbox", + }), + }), + ), + true, + ); + }); + + it.effect("wires desktop host discovery through the ssh package runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* makeTempHomeDir(); + const sshDir = path.join(homeDir, ".ssh"); + yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); + yield* fs.writeFileString( + path.join(sshDir, "config"), + ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), + ); + yield* fs.writeFileString( + path.join(sshDir, "config.d", "team.conf"), + [ + "Host staging", + " HostName staging.example.com", + "Host *", + " ServerAliveInterval 30", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + [ + "known.example.com ssh-ed25519 AAAA", + "|1|hashed|entry ssh-ed25519 AAAA", + "[bastion.example.com]:2222 ssh-ed25519 AAAA", + "", + ].join("\n"), + ); + + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + const hosts = yield* sshEnvironment.discoverHosts({ homeDir }); + assert.deepEqual(hosts, [ + { + alias: "bastion.example.com", + hostname: "bastion.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + source: "ssh-config", + }, + { + alias: "known.example.com", + hostname: "known.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "staging", + hostname: "staging", + username: null, + port: null, + source: "ssh-config", + }, + ]); + }).pipe( + Effect.provide( + DesktopSshEnvironment.layer().pipe( + Layer.provideMerge( + Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { + request: () => Effect.die("unexpected password prompt request"), + resolve: () => Effect.die("unexpected password prompt resolution"), + cancelPending: () => Effect.void, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + ), + ), + Effect.scoped, + ), + ); +}); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts new file mode 100644 index 00000000000..2fbf1f4357b --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -0,0 +1,150 @@ +import type { + DesktopDiscoveredSshHost, + DesktopSshEnvironmentBootstrap, + DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { + SshPasswordPrompt, + type SshPasswordPromptShape, + type SshPasswordRequest, +} from "@t3tools/ssh/auth"; +import { discoverSshHosts } from "@t3tools/ssh/config"; +import { + SshCommandError, + SshHostDiscoveryError, + SshInvalidTargetError, + SshLaunchError, + SshPairingError, + SshPasswordPromptError, + SshReadinessError, +} from "@t3tools/ssh/errors"; +import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +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 Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +export type DesktopSshEnvironmentRuntimeServices = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient + | NetService.NetService; + +export type DesktopSshEnvironmentOperationError = + | SshCommandError + | SshInvalidTargetError + | SshLaunchError + | SshPairingError + | SshReadinessError + | SshPasswordPromptError + | NetService.NetError; + +export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError; + +export type DesktopSshEnvironmentError = + | DesktopSshEnvironmentDiscoverError + | DesktopSshEnvironmentOperationError; + +export interface DesktopSshEnvironmentShape { + readonly discoverHosts: (input?: { + readonly homeDir?: string; + }) => Effect.Effect; + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; +} + +export class DesktopSshEnvironment extends Context.Service< + DesktopSshEnvironment, + DesktopSshEnvironmentShape +>()("t3/desktop/SshEnvironment") {} + +export interface DesktopSshEnvironmentLayerOptions { + readonly resolveCliPackageSpec?: () => string; + readonly resolveCliRunner?: Effect.Effect; +} + +function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { + return discoverSshHosts(input ?? {}); +} + +export function isDesktopSshPasswordPromptCancellation( + error: unknown, +): error is SshPasswordPromptError { + return ( + error instanceof SshPasswordPromptError && + DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) + ); +} + +const makePasswordPrompt = ( + prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, +): SshPasswordPromptShape => ({ + isAvailable: true, + request: (request: SshPasswordRequest) => + prompts.request(request).pipe( + Effect.mapError( + (cause) => + new SshPasswordPromptError({ + message: cause.message, + cause, + }), + ), + ), +}); + +const make = Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const runtimeContext = yield* Effect.context(); + const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); + + return DesktopSshEnvironment.of({ + discoverHosts: (input) => + discoverDesktopSshHostsEffect(input).pipe( + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.discoverHosts"), + ), + ensureEnvironment: (target, ensureOptions) => + manager + .ensureEnvironment(target, ensureOptions) + .pipe( + Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.ensureEnvironment"), + ), + disconnectEnvironment: (target) => + manager + .disconnectEnvironment(target) + .pipe( + Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.disconnectEnvironment"), + ), + }); +}); + +export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => + Layer.effect(DesktopSshEnvironment, make).pipe( + Layer.provide( + SshEnvironmentManager.layer({ + ...(options.resolveCliPackageSpec === undefined + ? {} + : { resolveCliPackageSpec: options.resolveCliPackageSpec }), + ...(options.resolveCliRunner === undefined + ? {} + : { resolveCliRunner: options.resolveCliRunner }), + }), + ), + ); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts new file mode 100644 index 00000000000..9e9cfcc737e --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -0,0 +1,144 @@ +import { assert, describe, it } from "@effect/vitest"; +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 TestClock from "effect/testing/TestClock"; +import type * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +interface SentMessage { + readonly channel: string; + readonly args: readonly unknown[]; +} + +function makeTestWindow() { + const listeners = new Map void>>(); + const sentMessages: SentMessage[] = []; + let destroyed = false; + let minimized = true; + let restored = false; + let focused = false; + + const window = { + isDestroyed: () => destroyed, + isMinimized: () => minimized, + restore: () => { + restored = true; + minimized = false; + }, + focus: () => { + focused = true; + }, + once: (eventName: string, listener: () => void) => { + const eventListeners = listeners.get(eventName) ?? new Set<() => void>(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }, + removeListener: (eventName: string, listener: () => void) => { + listeners.get(eventName)?.delete(listener); + }, + webContents: { + send: (channel: string, ...args: readonly unknown[]) => { + sentMessages.push({ channel, args }); + }, + }, + }; + + return { + window, + sentMessages, + isRestored: () => restored, + isFocused: () => focused, + close: () => { + destroyed = true; + const closedListeners = [...(listeners.get("closed") ?? [])]; + listeners.delete("closed"); + for (const listener of closedListeners) { + listener(); + } + }, + }; +} + +function makeElectronWindowLayer(window: ReturnType["window"]) { + return Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({ + create: () => Effect.die("unexpected BrowserWindow creation"), + main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + }), + ); +} + +function makeLayer(window: ReturnType["window"]) { + return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe( + Layer.provide(makeElectronWindowLayer(window)), + Layer.provideMerge(TestClock.layer()), + ); +} + +describe("DesktopSshPasswordPrompts", () => { + it.effect("sends renderer prompts and resolves them by request id", () => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + assert.equal(testWindow.sentMessages.length, 1); + const sent = testWindow.sentMessages[0]; + assert.ok(sent); + assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); + const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; + assert.equal(request.destination, "devbox"); + assert.equal(testWindow.isRestored(), true); + assert.equal(testWindow.isFocused(), true); + + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + assert.equal(yield* Fiber.join(fiber), "secret"); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("times out pending renderer prompts with a typed error", () => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: null, + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(1_000)); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError); + assert.equal(error.destination, "devbox"); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); +}); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts new file mode 100644 index 00000000000..a53de9fd8e4 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -0,0 +1,332 @@ +import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; +import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts"; +import type { SshPasswordRequest } from "@t3tools/ssh/auth"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +import * as IpcChannels from "../ipc/channels.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; + +const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; +const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; + +type DesktopSshPasswordPromptResolutionInput = + typeof DesktopSshPasswordPromptResolutionInputSchema.Type; + +export class DesktopSshPromptUnavailableError extends Data.TaggedError( + "DesktopSshPromptUnavailableError", +)<{ + readonly reason: string; +}> { + override get message() { + return this.reason; + } +} + +export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( + "DesktopSshPromptWindowUnavailableError", +)<{ + readonly destination: string; +}> { + override get message() { + return WINDOW_UNAVAILABLE_MESSAGE; + } +} + +export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ + readonly requestId: string; + readonly destination: string; + readonly cause: unknown; +}> { + override get message() { + return WINDOW_UNAVAILABLE_MESSAGE; + } +} + +export class DesktopSshPromptTimedOutError extends Data.TaggedError( + "DesktopSshPromptTimedOutError", +)<{ + readonly requestId: string; + readonly destination: string; +}> { + override get message() { + return `SSH authentication timed out for ${this.destination}.`; + } +} + +export class DesktopSshPromptCancelledError extends Data.TaggedError( + "DesktopSshPromptCancelledError", +)<{ + readonly requestId: string; + readonly destination: string; + readonly reason: string; +}> { + override get message() { + return this.reason; + } +} + +export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( + "DesktopSshPromptInvalidRequestIdError", +)<{ + readonly requestId: string; +}> { + override get message() { + return "Invalid SSH password prompt id."; + } +} + +export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ + readonly requestId: string; +}> { + override get message() { + return "SSH password prompt expired. Try connecting again."; + } +} + +export type DesktopSshPasswordPromptRequestError = + | DesktopSshPromptUnavailableError + | DesktopSshPromptWindowUnavailableError + | DesktopSshPromptSendError + | DesktopSshPromptTimedOutError + | DesktopSshPromptCancelledError; + +export type DesktopSshPasswordPromptResolveError = + | DesktopSshPromptInvalidRequestIdError + | DesktopSshPromptExpiredError; + +export type DesktopSshPasswordPromptError = + | DesktopSshPasswordPromptRequestError + | DesktopSshPasswordPromptResolveError; + +export function isDesktopSshPasswordPromptCancellation( + error: unknown, +): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { + return ( + error instanceof DesktopSshPromptCancelledError || + error instanceof DesktopSshPromptTimedOutError + ); +} + +export interface DesktopSshPasswordPromptsShape { + readonly request: ( + request: SshPasswordRequest, + ) => Effect.Effect; + readonly resolve: ( + input: DesktopSshPasswordPromptResolutionInput, + ) => Effect.Effect; + readonly cancelPending: (reason: string) => Effect.Effect; +} + +export class DesktopSshPasswordPrompts extends Context.Service< + DesktopSshPasswordPrompts, + DesktopSshPasswordPromptsShape +>()("t3/desktop/SshPasswordPrompts") {} + +interface PendingSshPasswordPrompt { + readonly requestId: string; + readonly destination: string; + readonly deferred: Deferred.Deferred; +} + +interface LayerOptions { + readonly passwordPromptTimeoutMs?: number; +} + +const removePending = ( + pendingRef: Ref.Ref>, + requestId: string, +) => + Ref.modify(pendingRef, (pending) => { + const entry = pending.get(requestId); + if (entry === undefined) { + return [Option.none(), pending] as const; + } + + const nextPending = new Map(pending); + nextPending.delete(requestId); + return [Option.some(entry), nextPending] as const; + }); + +const failPending = ( + pending: PendingSshPasswordPrompt, + error: DesktopSshPasswordPromptRequestError, +) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); + +const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const pendingRef = yield* Ref.make(new Map()); + const passwordPromptTimeoutMs = + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; + + const cancelPending = (reason: string): Effect.Effect => + Ref.getAndSet(pendingRef, new Map()).pipe( + Effect.flatMap((pending) => + Effect.forEach( + pending.values(), + (entry) => + failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId: entry.requestId, + destination: entry.destination, + reason, + }), + ), + { discard: true }, + ), + ), + Effect.asVoid, + ); + + yield* Effect.addFinalizer(() => + cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), + ); + + const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( + input: DesktopSshPasswordPromptResolutionInput, + ): Effect.fn.Return { + const requestId = input.requestId.trim(); + if (requestId.length === 0) { + return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); + } + + const pending = yield* removePending(pendingRef, requestId); + if (Option.isNone(pending)) { + return yield* new DesktopSshPromptExpiredError({ requestId }); + } + + const entry = pending.value; + if (input.password === null) { + yield* failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId, + destination: entry.destination, + reason: `SSH authentication cancelled for ${entry.destination}.`, + }), + ); + return; + } + + yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); + }); + + const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( + input: SshPasswordRequest, + ): Effect.fn.Return { + const window = yield* electronWindow.main; + if (Option.isNone(window) || window.value.isDestroyed()) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); + } + + const requestId = yield* Random.nextUUIDv4; + const now = yield* DateTime.now; + const expiresAt = DateTime.formatIso( + DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }), + ); + const promptRequest: DesktopSshPasswordPromptRequest = { + requestId, + destination: input.destination, + username: input.username, + prompt: input.prompt, + expiresAt, + }; + const deferred = yield* Deferred.make(); + const pending: PendingSshPasswordPrompt = { + requestId, + destination: input.destination, + deferred, + }; + yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); + + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const cancelOnWindowClosed = () => { + runFork( + removePending(pendingRef, requestId).pipe( + Effect.flatMap((entry) => + Option.match(entry, { + onNone: () => Effect.void, + onSome: (pending) => + failPending( + pending, + new DesktopSshPromptCancelledError({ + requestId, + destination: input.destination, + reason: "SSH authentication was cancelled because the app window closed.", + }), + ), + }), + ), + ), + ); + }; + const cleanup = Effect.sync(() => { + if (!window.value.isDestroyed()) { + window.value.removeListener("closed", cancelOnWindowClosed); + } + }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + const waitForPassword = Deferred.await(deferred).pipe( + Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new DesktopSshPromptTimedOutError({ + requestId, + destination: input.destination, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + return yield* Effect.try({ + try: () => { + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.once("closed", cancelOnWindowClosed); + window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + if (window.value.isMinimized()) { + window.value.restore(); + } + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.focus(); + }, + catch: (cause) => + new DesktopSshPromptSendError({ + requestId, + destination: input.destination, + cause, + }), + }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); + }); + + return DesktopSshPasswordPrompts.of({ + request, + resolve, + cancelPending, + }); +}); + +export const layer = (options: LayerOptions = {}) => + Layer.effect(DesktopSshPasswordPrompts, make(options)); diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts new file mode 100644 index 00000000000..8b6798d38cb --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts"; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function makeLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return DesktopSshRemoteApi.layer.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ), + ), + ); +} + +describe("DesktopSshRemoteApi", () => { + it.effect("fetches and decodes the remote environment descriptor", () => { + const requestUrls: string[] = []; + const layer = makeLayer((request) => + Effect.sync(() => { + requestUrls.push(request.url); + return jsonResponse(request, { + environmentId: "remote-env", + label: "Remote Devbox", + platform: { os: "linux", arch: "x64" }, + serverVersion: "1.2.3", + capabilities: { repositoryIdentity: true }, + }); + }), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({ + httpBaseUrl: "http://127.0.0.1:41773/", + }); + + assert.equal(descriptor.label, "Remote Devbox"); + assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("wraps schema decode failures in a typed remote api error", () => { + const layer = makeLayer((request) => + Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const error = yield* remoteApi + .fetchEnvironmentDescriptor({ + httpBaseUrl: "http://127.0.0.1:41773/", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError); + assert.equal(error.operation, "fetch-environment-descriptor"); + assert.equal(error.cause instanceof SshHttpBridgeError, false); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts new file mode 100644 index 00000000000..60184d098a6 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts @@ -0,0 +1,124 @@ +import { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, + type AuthBearerBootstrapResult as AuthBearerBootstrapResultType, + type AuthSessionState as AuthSessionStateType, + type AuthWebSocketTokenResult as AuthWebSocketTokenResultType, + ExecutionEnvironmentDescriptor, + type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType, +} from "@t3tools/contracts"; +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; + +export type DesktopSshRemoteApiOperation = + | "fetch-environment-descriptor" + | "bootstrap-bearer-session" + | "fetch-session-state" + | "issue-websocket-token"; + +export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{ + readonly operation: DesktopSshRemoteApiOperation; + readonly cause: SshHttpBridgeError | Schema.SchemaError; +}> { + override get message() { + return `SSH remote API request failed during ${this.operation}.`; + } +} + +export interface DesktopSshRemoteApiShape { + readonly fetchEnvironmentDescriptor: (input: { + readonly httpBaseUrl: string; + }) => Effect.Effect; + readonly bootstrapBearerSession: (input: { + readonly httpBaseUrl: string; + readonly credential: string; + }) => Effect.Effect; + readonly fetchSessionState: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; + readonly issueWebSocketToken: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; +} + +export class DesktopSshRemoteApi extends Context.Service< + DesktopSshRemoteApi, + DesktopSshRemoteApiShape +>()("t3/desktop/SshRemoteApi") {} + +const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( + ExecutionEnvironmentDescriptor, +); +const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); +const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); +const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); + +const mapError = + (operation: DesktopSshRemoteApiOperation) => + (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError => + new DesktopSshRemoteApiError({ operation, cause }); + +const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const provideHttpClient = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + + return DesktopSshRemoteApi.of({ + fetchEnvironmentDescriptor: ({ httpBaseUrl }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/.well-known/t3/environment", + }).pipe( + Effect.flatMap(decodeExecutionEnvironmentDescriptor), + Effect.mapError(mapError("fetch-environment-descriptor")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"), + ), + bootstrapBearerSession: ({ httpBaseUrl, credential }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { credential }, + }).pipe( + Effect.flatMap(decodeAuthBearerBootstrapResult), + Effect.mapError(mapError("bootstrap-bearer-session")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"), + ), + fetchSessionState: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/session", + bearerToken, + }).pipe( + Effect.flatMap(decodeAuthSessionState), + Effect.mapError(mapError("fetch-session-state")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"), + ), + issueWebSocketToken: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken, + }).pipe( + Effect.flatMap(decodeAuthWebSocketTokenResult), + Effect.mapError(mapError("issue-websocket-token")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"), + ), + }); +}); + +export const layer = Layer.effect(DesktopSshRemoteApi, make); diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts deleted file mode 100644 index cda78a20b2c..00000000000 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { syncShellEnvironment } from "./syncShellEnvironment"; - -describe("syncShellEnvironment", () => { - it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); - }); - - it("preserves an inherited SSH_AUTH_SOCK value", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/login-shell.sock", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); - - it("preserves inherited values when the login shell omits them", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); - - it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - })); - - syncShellEnvironment(env, { - platform: "linux", - readEnvironment, - }); - - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); - expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); - }); - - it("does nothing outside macOS and linux", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "C:/Program Files/Git/bin/bash.exe", - PATH: "C:\\Windows\\System32", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/usr/local/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - })); - - syncShellEnvironment(env, { - platform: "win32", - readEnvironment, - }); - - expect(readEnvironment).not.toHaveBeenCalled(); - expect(env.PATH).toBe("C:\\Windows\\System32"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); -}); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts deleted file mode 100644 index 13036149b8d..00000000000 --- a/apps/desktop/src/syncShellEnvironment.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - readEnvironmentFromLoginShell, - resolveLoginShell, - ShellEnvironmentReader, -} from "@t3tools/shared/shell"; - -export function syncShellEnvironment( - env: NodeJS.ProcessEnv = process.env, - options: { - platform?: NodeJS.Platform; - readEnvironment?: ShellEnvironmentReader; - } = {}, -): void { - const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; - - try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; - - const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ - "PATH", - "SSH_AUTH_SOCK", - ]); - - if (shellEnvironment.PATH) { - env.PATH = shellEnvironment.PATH; - } - - if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { - env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; - } - } catch { - // Keep inherited environment if shell lookup fails. - } -} diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts deleted file mode 100644 index 8db92e1915f..00000000000 --- a/apps/desktop/src/updateState.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DesktopUpdateState } from "@t3tools/contracts"; - -import { - getCanRetryAfterDownloadFailure, - getAutoUpdateDisabledReason, - nextStatusAfterDownloadFailure, - shouldBroadcastDownloadProgress, -} from "./updateState"; - -const baseState: DesktopUpdateState = { - enabled: true, - status: "idle", - currentVersion: "1.0.0", - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, -}; - -describe("shouldBroadcastDownloadProgress", () => { - it("broadcasts the first downloading progress update", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: null }, - 1, - ), - ).toBe(true); - }); - - it("skips progress updates within the same 10% bucket", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 11.2 }, - 18.7, - ), - ).toBe(false); - }); - - it("broadcasts progress updates when a new 10% bucket is reached", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 19.9 }, - 20.1, - ), - ).toBe(true); - }); - - it("broadcasts progress updates when a retry resets the download percentage", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 50.4 }, - 0.2, - ), - ).toBe(true); - }); -}); - -describe("getAutoUpdateDisabledReason", () => { - it("reports development builds as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: true, - isPackaged: false, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toContain("packaged production builds"); - }); - - it("reports packaged local builds without an update feed as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: false, - }), - ).toContain("no update feed"); - }); - - it("allows packaged builds with an update feed", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toBeNull(); - }); - - it("reports env-disabled auto updates", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: true, - hasUpdateFeedConfig: true, - }), - ).toContain("T3CODE_DISABLE_AUTO_UPDATE"); - }); - - it("reports linux non-AppImage builds as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "linux", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toContain("AppImage"); - }); -}); - -describe("nextStatusAfterDownloadFailure", () => { - it("returns available when an update version is still known", () => { - expect( - nextStatusAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: "1.1.0", - }), - ).toBe("available"); - }); - - it("returns error when no update version can be retried", () => { - expect( - nextStatusAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: null, - }), - ).toBe("error"); - }); -}); - -describe("getCanRetryAfterDownloadFailure", () => { - it("returns true when an available version is still present", () => { - expect( - getCanRetryAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: "1.1.0", - }), - ).toBe(true); - }); - - it("returns false when no version is available to retry", () => { - expect( - getCanRetryAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: null, - }), - ).toBe(false); - }); -}); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts deleted file mode 100644 index 928bb408865..00000000000 --- a/apps/desktop/src/updateState.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; - -export function shouldBroadcastDownloadProgress( - currentState: DesktopUpdateState, - nextPercent: number, -): boolean { - if (currentState.status !== "downloading") { - return true; - } - - const currentPercent = currentState.downloadPercent; - if (currentPercent === null) { - return true; - } - - const previousStep = Math.floor(currentPercent / 10); - const nextStep = Math.floor(nextPercent / 10); - return nextStep !== previousStep || nextPercent === 100; -} - -export function nextStatusAfterDownloadFailure( - currentState: DesktopUpdateState, -): DesktopUpdateState["status"] { - return currentState.availableVersion ? "available" : "error"; -} - -export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { - return currentState.availableVersion !== null; -} - -export function getAutoUpdateDisabledReason(args: { - isDevelopment: boolean; - isPackaged: boolean; - platform: NodeJS.Platform; - appImage?: string | undefined; - disabledByEnv: boolean; - hasUpdateFeedConfig: boolean; -}): string | null { - if (!args.hasUpdateFeedConfig) { - return "Automatic updates are not available because no update feed is configured."; - } - if (args.isDevelopment || !args.isPackaged) { - return "Automatic updates are only available in packaged production builds."; - } - if (args.disabledByEnv) { - return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; - } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; - } - return null; -} diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts new file mode 100644 index 00000000000..34d18f11a77 --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -0,0 +1,295 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 TestClock from "effect/testing/TestClock"; + +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; + +interface UpdatesHarnessOptions { + readonly checkForUpdates?: Effect.Effect< + void, + ElectronUpdater.ElectronUpdaterCheckForUpdatesError + >; + readonly env?: Record; +} + +const flushCallbacks = Effect.yieldNow; + +function makeHarness(options: UpdatesHarnessOptions = {}) { + let checkCount = 0; + let allowDowngrade = false; + const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; + const listeners = new Map void>>(); + const sentStates: DesktopUpdateState[] = []; + + const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName) ?? new Set(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }; + + const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return; + } + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + }; + + const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { + setFeedURL: (options) => + Effect.sync(() => { + feedUrls.push(options); + }), + setAutoDownload: () => Effect.void, + setAutoInstallOnAppQuit: () => Effect.void, + setChannel: () => Effect.void, + setAllowPrerelease: () => Effect.void, + allowDowngrade: Effect.sync(() => allowDowngrade), + setAllowDowngrade: (value) => + Effect.sync(() => { + allowDowngrade = value; + }), + setDisableDifferentialDownload: () => Effect.void, + checkForUpdates: Effect.sync(() => { + checkCount += 1; + }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), + downloadUpdate: Effect.void, + quitAndInstall: () => Effect.void, + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + () => + Effect.sync(() => { + removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronUpdater.ElectronUpdaterShape); + + const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die("unexpected BrowserWindow creation"), + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeed(Option.none()), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: (_channel, state) => + Effect.sync(() => { + sentStates.push(state as DesktopUpdateState); + }), + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + } satisfies ElectronWindow.ElectronWindowShape); + + const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }), + }); + + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + ), + ); + + const layer = DesktopUpdates.layer.pipe( + Layer.provideMerge(updaterLayer), + Layer.provideMerge(windowLayer), + Layer.provideMerge(backendLayer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge( + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return { + layer, + checkCount: () => checkCount, + feedUrls: () => feedUrls, + listenerCount: () => + Array.from(listeners.values()).reduce( + (total, eventListeners) => total + eventListeners.size, + 0, + ), + sentStates, + emit: (eventName: string, payload?: unknown) => { + for (const listener of listeners.get(eventName) ?? []) { + listener(payload); + } + }, + }; +} + +describe("DesktopUpdates", () => { + it.effect("configures the updater and runs startup checks on the test clock", () => { + const harness = makeHarness(); + + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.getState; + assert.equal(state.enabled, true); + assert.equal(state.status, "idle"); + assert.deepEqual(harness.feedUrls(), [ + { provider: "generic", url: "http://localhost:4141" }, + ]); + assert.equal(harness.listenerCount(), 6); + assert.equal(harness.checkCount(), 0); + + yield* TestClock.adjust(Duration.millis(15_000)); + assert.equal(harness.checkCount(), 1); + }), + ); + + assert.equal(harness.listenerCount(), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("updates and broadcasts state from updater events", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.equal(state.availableVersion, "1.2.4"); + assert.isNotNull(state.checkedAt); + assert.equal(harness.sentStates.at(-1)?.status, "available"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("persists channel changes through the settings service", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.setChannel("nightly"); + const persistedSettings = yield* settings.get; + + assert.equal(state.channel, "nightly"); + assert.equal(persistedSettings.updateChannel, "nightly"); + assert.equal(persistedSettings.updateChannelConfiguredByUser, true); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("does not persist an unchanged update channel as a user preference", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.setChannel("latest"); + const persistedSettings = yield* settings.get; + + assert.equal(state.channel, "latest"); + assert.equal(persistedSettings.updateChannel, "latest"); + assert.equal(persistedSettings.updateChannelConfiguredByUser, false); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("fails channel changes with a typed error while a check is in progress", () => + Effect.gen(function* () { + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const harness = makeHarness({ + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); + yield* Deferred.await(checkStarted); + + const exit = yield* Effect.exit(updates.setChannel("nightly")); + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); + assert.equal(error.action, "check"); + } + + yield* Deferred.succeed(releaseCheck, undefined); + yield* Fiber.join(checkFiber); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); +}); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts new file mode 100644 index 00000000000..8c0acd2e8a6 --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -0,0 +1,667 @@ +import type { + DesktopRuntimeInfo, + DesktopUpdateActionResult, + DesktopUpdateChannel, + DesktopUpdateCheckResult, + DesktopUpdateState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { + createInitialDesktopUpdateState, + reduceDesktopUpdateStateOnCheckFailure, + reduceDesktopUpdateStateOnCheckStart, + reduceDesktopUpdateStateOnDownloadComplete, + reduceDesktopUpdateStateOnDownloadFailure, + reduceDesktopUpdateStateOnDownloadProgress, + reduceDesktopUpdateStateOnDownloadStart, + reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateOnUpdateAvailable, +} from "./updateMachine.ts"; + +const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; +const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; + +const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); +type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; + +const UpdateInfo = Schema.Struct({ + version: Schema.String, +}); + +const DownloadProgressInfo = Schema.Struct({ + percent: Schema.Number, +}); +const decodeAppUpdateYmlConfig = Schema.decodeUnknownEffect(AppUpdateYmlConfig); +const decodeUpdateInfo = Schema.decodeUnknownEffect(UpdateInfo); +const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressInfo); + +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +export class DesktopUpdateActionInProgressError extends Data.TaggedError( + "DesktopUpdateActionInProgressError", +)<{ + readonly action: "check" | "download" | "install"; +}> { + override get message() { + return `Cannot change update tracks while an update ${this.action} action is in progress.`; + } +} + +export class DesktopUpdatePersistenceError extends Data.TaggedError( + "DesktopUpdatePersistenceError", +)<{ + readonly cause: DesktopAppSettings.DesktopSettingsWriteError; +}> { + override get message() { + return "Failed to persist desktop update settings."; + } +} + +export type DesktopUpdateConfigureError = never; + +export type DesktopUpdateSetChannelError = + | DesktopUpdateActionInProgressError + | DesktopUpdatePersistenceError; + +export interface DesktopUpdatesShape { + readonly getState: Effect.Effect; + readonly emitState: Effect.Effect; + readonly disabledReason: Effect.Effect>; + readonly configure: Effect.Effect; + readonly setChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; + readonly check: (reason: string) => Effect.Effect; + readonly download: Effect.Effect; + readonly install: Effect.Effect; +} + +export class DesktopUpdates extends Context.Service()( + "t3/desktop/Updates", +) {} + +const { + logInfo: logUpdaterInfo, + logWarning: logUpdaterWarning, + logError: logUpdaterError, +} = DesktopObservability.makeComponentLogger("desktop-updater"); + +function parseAppUpdateYml(raw: string): Effect.Effect> { + const entries: Record = {}; + for (const line of raw.split("\n")) { + const match = line.match(/^(\w+):\s*(.+)$/); + if (match?.[1] && match[2]) { + entries[match[1]] = match[2].trim(); + } + } + + return decodeAppUpdateYmlConfig(entries).pipe( + Effect.map((config) => (config.provider ? Option.some(config) : Option.none())), + Effect.catch(() => Effect.succeed(Option.none())), + ); +} + +function createBaseUpdateState( + channel: DesktopUpdateChannel, + enabled: boolean, + environment: DesktopEnvironment.DesktopEnvironmentShape, +): DesktopUpdateState { + return { + ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), + enabled, + status: enabled ? "idle" : "disabled", + }; +} + +function getCanRetryFromState(state: DesktopUpdateState): boolean { + return state.availableVersion !== null || state.downloadedVersion !== null; +} + +function shouldBroadcastDownloadProgress( + currentState: DesktopUpdateState, + nextPercent: number, +): boolean { + if (currentState.status !== "downloading") { + return true; + } + + const currentPercent = currentState.downloadPercent; + if (currentPercent === null) { + return true; + } + + const previousStep = Math.floor(currentPercent / 10); + const nextStep = Math.floor(nextPercent / 10); + return nextStep !== previousStep || nextPercent === 100; +} + +function getAutoUpdateDisabledReason(args: { + isDevelopment: boolean; + isPackaged: boolean; + platform: NodeJS.Platform; + appImage?: string | undefined; + disabledByEnv: boolean; + hasUpdateFeedConfig: boolean; +}): string | null { + if (!args.hasUpdateFeedConfig) { + return "Automatic updates are not available because no update feed is configured."; + } + if (args.isDevelopment || !args.isPackaged) { + return "Automatic updates are only available in packaged production builds."; + } + if (args.disabledByEnv) { + return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; + } + if (args.platform === "linux" && !args.appImage) { + return "Automatic updates on Linux require running the AppImage build."; + } + return null; +} + +function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { + return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; +} + +const make = Effect.gen(function* () { + const config = yield* DesktopConfig.DesktopConfig; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const desktopState = yield* DesktopState.DesktopState; + const electronUpdater = yield* ElectronUpdater.ElectronUpdater; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + + const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); + const updateCheckInFlightRef = yield* Ref.make(false); + const updateDownloadInFlightRef = yield* Ref.make(false); + const updateInstallInFlightRef = yield* Ref.make(false); + const updaterConfiguredRef = yield* Ref.make(false); + const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); + const updateStateRef = yield* Ref.make( + createInitialDesktopUpdateState( + environment.appVersion, + environment.runtimeInfo, + environment.defaultDesktopSettings.updateChannel, + ), + ); + + const emitState = Ref.get(updateStateRef).pipe( + Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), + ); + + const setState = (state: DesktopUpdateState): Effect.Effect => + Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState)); + + const updateState = ( + f: (state: DesktopUpdateState) => DesktopUpdateState, + ): Effect.Effect => + Ref.get(updateStateRef).pipe( + Effect.flatMap((state) => { + const nextState = f(state); + return setState(nextState).pipe(Effect.as(nextState)); + }), + ); + + const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: parseAppUpdateYml, + }), + ), + ); + + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( + Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), + ); + + const resolveDisabledReason = Effect.gen(function* () { + const hasFeedConfig = yield* hasUpdateFeedConfig; + return Option.fromNullishOr( + getAutoUpdateDisabledReason({ + isDevelopment: environment.isDevelopment, + isPackaged: environment.isPackaged, + platform: environment.platform, + appImage: Option.getOrUndefined(config.appImagePath), + disabledByEnv: config.disableAutoUpdate, + hasUpdateFeedConfig: hasFeedConfig, + }), + ); + }); + + const resolveUpdaterErrorContext = Effect.gen(function* () { + if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const; + if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const; + if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const; + return (yield* Ref.get(updateStateRef)).errorContext; + }); + + const activeUpdateAction = Effect.gen(function* () { + if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const); + if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const); + if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const); + return Option.none<"check" | "download" | "install">(); + }); + + const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( + channel: DesktopUpdateChannel, + ) { + yield* Effect.annotateCurrentSpan({ channel }); + const allowsPrerelease = channel === "nightly"; + yield* electronUpdater.setChannel(channel); + yield* electronUpdater.setAllowPrerelease(allowsPrerelease); + yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* logUpdaterInfo("using update channel", { + channel, + allowPrerelease: allowsPrerelease, + allowDowngrade: allowsPrerelease, + }); + }); + + const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); + + const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) { + yield* Effect.annotateCurrentSpan({ reason }); + if (yield* Ref.get(desktopState.quitting)) return false; + if (!(yield* Ref.get(updaterConfiguredRef))) return false; + if (yield* Ref.get(updateCheckInFlightRef)) return false; + + const state = yield* Ref.get(updateStateRef); + if (state.status === "downloading" || state.status === "downloaded") { + yield* logUpdaterInfo("skipping update check while update is active", { + reason, + status: state.status, + }); + return false; + } + + yield* Ref.set(updateCheckInFlightRef, true); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); + yield* logUpdaterInfo("checking for updates", { reason }); + + return yield* electronUpdater.checkForUpdates.pipe( + Effect.as(true), + Effect.catch( + Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { + const failedAt = yield* currentIsoTimestamp; + yield* updateState((current) => + reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), + ); + yield* logUpdaterError("failed to check for updates", { message: error.message }); + return true; + }), + ), + Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), + ); + }); + + const downloadAvailableUpdate = Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if ( + !(yield* Ref.get(updaterConfiguredRef)) || + (yield* Ref.get(updateDownloadInFlightRef)) || + state.status !== "available" + ) { + return { accepted: false, completed: false }; + } + + yield* Ref.set(updateDownloadInFlightRef, true); + return yield* Effect.gen(function* () { + yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + yield* logUpdaterInfo("downloading update"); + yield* electronUpdater.downloadUpdate; + return { accepted: true, completed: true }; + }).pipe( + Effect.catch( + Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), + ); + yield* logUpdaterError("failed to download update", { message: error.message }); + return { accepted: true, completed: false }; + }), + ), + Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), + ); + }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + + const installDownloadedUpdate = Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if ( + (yield* Ref.get(desktopState.quitting)) || + !(yield* Ref.get(updaterConfiguredRef)) || + state.status !== "downloaded" + ) { + return { accepted: false, completed: false }; + } + + yield* Ref.set(desktopState.quitting, true); + yield* Ref.set(updateInstallInFlightRef, true); + + return yield* Effect.gen(function* () { + yield* backendManager.stop({ timeout: Duration.seconds(5) }); + yield* electronWindow.destroyAll; + yield* electronUpdater.quitAndInstall({ + isSilent: true, + isForceRunAfter: true, + }); + return { accepted: true, completed: false }; + }).pipe( + Effect.catch( + Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { + yield* Ref.set(updateInstallInFlightRef, false); + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* Ref.set(desktopState.quitting, false); + yield* logUpdaterError("failed to install update", { message: error.message }); + return { accepted: true, completed: false }; + }), + ), + ); + }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); + + const startUpdatePollers: Effect.Effect = Effect.gen(function* () { + yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( + Effect.andThen(checkForUpdates("startup")), + Effect.catchCause((cause) => + logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkScoped, + ); + yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( + Effect.andThen(checkForUpdates("poll")), + Effect.forever, + Effect.catchCause((cause) => + logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("desktop.updates.startPollers")); + + const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* ( + raw: unknown, + ) { + yield* decodeUpdateInfo(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { + const state = yield* Ref.get(updateStateRef); + if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { + yield* logUpdaterInfo("ignoring update that does not match selected channel", { + version: info.version, + channel: state.channel, + }); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + return; + } + + const checkedAt = yield* currentIsoTimestamp; + yield* setState( + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + ); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + yield* logUpdaterInfo("update available", { version: info.version }); + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed update-available event", { + cause: Cause.pretty(cause), + }), + ), + ); + }); + + const handleUpdateNotAvailable = Effect.gen(function* () { + const checkedAt = yield* currentIsoTimestamp; + const state = yield* Ref.get(updateStateRef); + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + yield* logUpdaterInfo("no updates available"); + }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); + + const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( + error: unknown, + ) { + const message = error instanceof Error ? error.message : String(error); + if (yield* Ref.get(updateInstallInFlightRef)) { + yield* Ref.set(updateInstallInFlightRef, false); + yield* Ref.set(desktopState.quitting, false); + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); + yield* logUpdaterError("updater error", { message }); + return; + } + + if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) { + const errorContext = yield* resolveUpdaterErrorContext; + const checkedAt = yield* currentIsoTimestamp; + yield* updateState((current) => ({ + ...current, + status: "error", + message, + checkedAt, + downloadPercent: null, + errorContext, + canRetry: getCanRetryFromState(current), + })); + } + + yield* logUpdaterError("updater error", { message }); + }); + + const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( + raw: unknown, + ) { + yield* decodeDownloadProgressInfo(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) { + const state = yield* Ref.get(updateStateRef); + const percent = Math.floor(progress.percent); + if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) { + yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent)); + } + const milestone = percent - (percent % 10); + const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef); + if (milestone > lastLoggedMilestone) { + yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone); + yield* logUpdaterInfo("download progress", { percent }); + } + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed download-progress event", { + cause: Cause.pretty(cause), + }), + ), + ); + }); + + const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* ( + raw: unknown, + ) { + yield* decodeUpdateInfo(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { + const state = yield* Ref.get(updateStateRef); + yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); + yield* logUpdaterInfo("update downloaded", { version: info.version }); + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed update-downloaded event", { + cause: Cause.pretty(cause), + }), + ), + ); + }); + + return DesktopUpdates.of({ + getState: Ref.get(updateStateRef), + emitState, + disabledReason: resolveDisabledReason, + configure: Effect.gen(function* () { + const context = yield* Effect.context(); + const runEffect = (effect: Effect.Effect) => { + void Effect.runPromiseWith(context)(effect); + }; + + const appUpdateYmlConfig = yield* readAppUpdateYml; + yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); + + if (config.mockUpdates) { + yield* electronUpdater.setFeedURL({ + provider: "generic", + url: `http://localhost:${config.mockUpdateServerPort}`, + } as ElectronUpdater.ElectronUpdaterFeedUrl); + } + + const settings = yield* desktopSettings.get; + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); + if (!enabled) { + return; + } + yield* Ref.set(updaterConfiguredRef, true); + + yield* electronUpdater.setAutoDownload(false); + yield* electronUpdater.setAutoInstallOnAppQuit(false); + yield* applyAutoUpdaterChannel(settings.updateChannel); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + + if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) { + yield* logUpdaterInfo( + "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages", + ); + } + + yield* electronUpdater.on("checking-for-update", () => { + runEffect( + logUpdaterInfo("looking for updates").pipe( + Effect.withSpan("desktop.updates.handleCheckingForUpdate"), + ), + ); + }); + yield* electronUpdater.on("update-available", (info: unknown) => { + runEffect(handleUpdateAvailable(info)); + }); + yield* electronUpdater.on("update-not-available", () => { + runEffect(handleUpdateNotAvailable); + }); + yield* electronUpdater.on("error", (error: unknown) => { + runEffect(handleUpdaterError(error)); + }); + yield* electronUpdater.on("download-progress", (progress: unknown) => { + runEffect(handleDownloadProgress(progress)); + }); + yield* electronUpdater.on("update-downloaded", (info: unknown) => { + runEffect(handleUpdateDownloaded(info)); + }); + + yield* startUpdatePollers; + }).pipe(Effect.withSpan("desktop.updates.configure")), + setChannel: Effect.fn("desktop.updates.setChannel")(function* ( + nextChannel: DesktopUpdateChannel, + ) { + yield* Effect.annotateCurrentSpan({ channel: nextChannel }); + const activeAction = yield* activeUpdateAction; + if (Option.isSome(activeAction)) { + return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); + } + + const state = yield* Ref.get(updateStateRef); + if (nextChannel === state.channel) { + return state; + } + + yield* desktopSettings + .setUpdateChannel(nextChannel) + .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); + + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); + + if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { + return yield* Ref.get(updateStateRef); + } + + yield* applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = yield* electronUpdater.allowDowngrade; + yield* electronUpdater.setAllowDowngrade(true); + yield* checkForUpdates("channel-change").pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), + ); + return yield* Ref.get(updateStateRef); + }), + check: Effect.fn("desktop.updates.check")(function* (reason: string) { + yield* Effect.annotateCurrentSpan({ reason }); + if (!(yield* Ref.get(updaterConfiguredRef))) { + return { + checked: false, + state: yield* Ref.get(updateStateRef), + }; + } + const checked = yield* checkForUpdates(reason); + return { + checked, + state: yield* Ref.get(updateStateRef), + }; + }), + download: Effect.gen(function* () { + const result = yield* downloadAvailableUpdate; + return { + accepted: result.accepted, + completed: result.completed, + state: yield* Ref.get(updateStateRef), + }; + }).pipe(Effect.withSpan("desktop.updates.download")), + install: Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) { + return { + accepted: false, + completed: false, + state: yield* Ref.get(updateStateRef), + }; + } + const result = yield* installDownloadedUpdate; + return { + accepted: result.accepted, + completed: result.completed, + state: yield* Ref.get(updateStateRef), + }; + }).pipe(Effect.withSpan("desktop.updates.install")), + }); +}); + +export const layer = Layer.effect(DesktopUpdates, make); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts new file mode 100644 index 00000000000..731910e441f --- /dev/null +++ b/apps/desktop/src/updates/updateChannels.ts @@ -0,0 +1,11 @@ +import type { DesktopUpdateChannel } from "@t3tools/contracts"; + +const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; + +export function isNightlyDesktopVersion(version: string): boolean { + return NIGHTLY_VERSION_PATTERN.test(version); +} + +export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { + return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; +} diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts similarity index 88% rename from apps/desktop/src/updateMachine.test.ts rename to apps/desktop/src/updates/updateMachine.test.ts index 7fbc982eff8..e2f0519d350 100644 --- a/apps/desktop/src/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -11,7 +11,7 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; +} from "./updateMachine.ts"; const runtimeInfo = { hostArch: "x64", @@ -23,7 +23,7 @@ describe("updateMachine", () => { it("clears transient errors when a check starts", () => { const state = reduceDesktopUpdateStateOnCheckStart( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "error", message: "network", @@ -42,7 +42,7 @@ describe("updateMachine", () => { it("records a check failure without exposing an action", () => { const state = reduceDesktopUpdateStateOnCheckFailure( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "checking", }, @@ -58,7 +58,7 @@ describe("updateMachine", () => { it("preserves available version on download failure for retry", () => { const state = reduceDesktopUpdateStateOnDownloadFailure( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "downloading", availableVersion: "1.1.0", @@ -76,7 +76,7 @@ describe("updateMachine", () => { it("transitions to downloaded and then preserves install retry state", () => { const downloaded = reduceDesktopUpdateStateOnDownloadComplete( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "downloading", availableVersion: "1.1.0", @@ -98,7 +98,7 @@ describe("updateMachine", () => { it("clears stale download state when no update is available", () => { const state = reduceDesktopUpdateStateOnNoUpdate( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "error", availableVersion: "1.1.0", @@ -120,7 +120,7 @@ describe("updateMachine", () => { it("tracks available, download start, and progress cleanly", () => { const available = reduceDesktopUpdateStateOnUpdateAvailable( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "checking", }, @@ -131,6 +131,7 @@ describe("updateMachine", () => { const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); + expect(available.channel).toBe("latest"); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts similarity index 87% rename from apps/desktop/src/updateMachine.ts rename to apps/desktop/src/updates/updateMachine.ts index f13b4202810..b5037225774 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,14 +1,28 @@ -import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts"; +import type { + DesktopRuntimeInfo, + DesktopUpdateChannel, + DesktopUpdateState, +} from "@t3tools/contracts"; -import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; +export function nextStatusAfterDownloadFailure( + currentState: DesktopUpdateState, +): DesktopUpdateState["status"] { + return currentState.availableVersion ? "available" : "error"; +} + +export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { + return currentState.availableVersion !== null; +} export function createInitialDesktopUpdateState( currentVersion: string, runtimeInfo: DesktopRuntimeInfo, + channel: DesktopUpdateChannel, ): DesktopUpdateState { return { enabled: false, status: "disabled", + channel, currentVersion, hostArch: runtimeInfo.hostArch, appArch: runtimeInfo.appArch, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts new file mode 100644 index 00000000000..fc589b3e39b --- /dev/null +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -0,0 +1,132 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +const environmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "linux", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const electronAppLayer = 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, + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void, +} satisfies ElectronApp.ElectronAppShape); + +const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { + pickFolder: () => Effect.succeed(Option.none()), + confirm: () => Effect.succeed(false), + showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), + showErrorBox: () => Effect.void, +} satisfies ElectronDialog.ElectronDialogShape); + +const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { + getState: Effect.die("unexpected getState"), + emitState: Effect.void, + disabledReason: Effect.succeed(Option.none()), + configure: Effect.void, + setChannel: () => Effect.die("unexpected setChannel"), + check: () => Effect.die("unexpected check"), + download: Effect.die("unexpected download"), + install: Effect.die("unexpected install"), +} satisfies DesktopUpdates.DesktopUpdatesShape); + +const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + handleBackendReady: Effect.void, + dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindowShape); + +const makeElectronMenuLayer = ( + applicationMenuTemplate: Deferred.Deferred, +) => + Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: (template) => + Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), + popupTemplate: () => Effect.void, + showContextMenu: () => Effect.succeed(Option.none()), + } satisfies ElectronMenu.ElectronMenuShape); + +describe("DesktopApplicationMenu", () => { + it.effect("installs the native menu and routes Settings through DesktopWindow", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + + const template = yield* Deferred.await(applicationMenuTemplate); + const fileMenu = template.find((item) => item.label === "File"); + assert.isDefined(fileMenu); + if (!Array.isArray(fileMenu.submenu)) { + throw new Error("Expected File menu submenu to be an array."); + } + const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings..."); + assert.isDefined(settingsItem); + const settingsClick = settingsItem.click; + if (typeof settingsClick !== "function") { + throw new Error("Expected Settings menu item to have a click handler."); + } + + settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "open-settings"); + }), + ); +}); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts new file mode 100644 index 00000000000..5e65d81910a --- /dev/null +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -0,0 +1,212 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import type * as Electron from "electron"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +export interface DesktopApplicationMenuShape { + readonly configure: Effect.Effect; +} + +export class DesktopApplicationMenu extends Context.Service< + DesktopApplicationMenu, + DesktopApplicationMenuShape +>()("t3/desktop/ApplicationMenu") {} + +type DesktopApplicationMenuRuntimeServices = + | DesktopUpdates.DesktopUpdates + | DesktopWindow.DesktopWindow + | ElectronDialog.ElectronDialog; + +const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); + +const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); + +const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( + action: string, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.dispatchMenuAction(action); +}); + +const checkForUpdatesFromMenu: Effect.Effect< + void, + never, + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const result = yield* updates.check("menu"); + const updateState = result.state; + + if (updateState.status === "up-to-date") { + yield* electronDialog.showMessageBox({ + type: "info", + title: "You're up to date!", + message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + buttons: ["OK"], + }); + } else if (updateState.status === "error") { + yield* electronDialog.showMessageBox({ + type: "warning", + title: "Update check failed", + message: "Could not check for updates.", + detail: updateState.message ?? "An unknown error occurred. Please try again later.", + buttons: ["OK"], + }); + } +}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); + +const handleCheckForUpdatesMenuClick: Effect.Effect< + void, + DesktopWindow.DesktopWindowError, + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const disabledReason = yield* updates.disabledReason; + if (Option.isSome(disabledReason)) { + yield* logUpdaterInfo("manual update check requested, but updates are disabled", { + disabledReason: disabledReason.value, + }); + yield* electronDialog.showMessageBox({ + type: "info", + title: "Updates unavailable", + message: "Automatic updates are not available right now.", + detail: disabledReason.value, + buttons: ["OK"], + }); + return; + } + + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + yield* checkForUpdatesFromMenu; +}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); + +const make = Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appName = yield* electronApp.name; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const runMenuEffect = ( + action: string, + effect: Effect.Effect, + ) => { + void runPromise( + effect.pipe( + Effect.annotateLogs({ action }), + Effect.withSpan("desktop.menu.action"), + Effect.catchCause((cause) => + logMenuError("desktop menu action failed", { + action, + cause: Cause.pretty(cause), + }), + ), + ), + ); + }; + + const configure = Effect.gen(function* () { + const checkForUpdatesClick = () => { + runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); + }; + const settingsClick = () => { + runMenuEffect("open-settings", dispatchMenuAction("open-settings")); + }; + const template: Electron.MenuItemConstructorOptions[] = []; + + if (environment.platform === "darwin") { + template.push({ + label: appName, + submenu: [ + { role: "about" }, + { + label: "Check for Updates...", + click: checkForUpdatesClick, + }, + { type: "separator" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: settingsClick, + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + template.push( + { + label: "File", + submenu: [ + ...(environment.platform === "darwin" + ? [] + : [ + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: settingsClick, + }, + { type: "separator" as const }, + ]), + { role: environment.platform === "darwin" ? "close" : "quit" }, + ], + }, + { role: "editMenu" }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { role: "windowMenu" }, + { + role: "help", + submenu: [ + { + label: "Check for Updates...", + click: checkForUpdatesClick, + }, + ], + }, + ); + + yield* electronMenu.setApplicationMenu(template); + }).pipe(Effect.withSpan("desktop.menu.configure")); + + return DesktopApplicationMenu.of({ + configure, + }); +}); + +export const layer = Layer.effect(DesktopApplicationMenu, make); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts new file mode 100644 index 00000000000..fbcc60934aa --- /dev/null +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -0,0 +1,180 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 type * as Electron from "electron"; +import { vi } from "vitest"; + +import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +const environmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +function makeFakeBrowserWindow() { + const webContents = { + copyImageAt: vi.fn(), + isLoadingMainFrame: vi.fn(() => false), + on: vi.fn(), + once: vi.fn(), + openDevTools: vi.fn(), + replaceMisspelling: vi.fn(), + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + + const window = { + focus: vi.fn(), + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => false), + isVisible: vi.fn(() => true), + loadURL: vi.fn(() => Promise.resolve()), + on: vi.fn(), + once: vi.fn(), + restore: vi.fn(), + setBackgroundColor: vi.fn(), + setTitle: vi.fn(), + setTitleBarOverlay: vi.fn(), + show: vi.fn(), + webContents, + }; + + return { + window: window as unknown as Electron.BrowserWindow, + loadURL: window.loadURL, + openDevTools: webContents.openDevTools, + }; +} + +const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.none(), + icns: Option.none(), + png: Option.none(), + }), + resolveResourcePath: () => Effect.succeed(Option.none()), +} satisfies DesktopAssets.DesktopAssetsShape); + +const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 3773, + bindHost: "127.0.0.1", + httpBaseUrl: new URL("http://127.0.0.1:3773"), + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), +} satisfies DesktopServerExposure.DesktopServerExposureShape); + +const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: () => Effect.void, + popupTemplate: () => Effect.void, + showContextMenu: () => Effect.succeed(Option.none()), +} satisfies ElectronMenu.ElectronMenuShape); + +const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { + openExternal: () => Effect.succeed(true), + copyText: () => Effect.void, +} satisfies ElectronShell.ElectronShellShape); + +const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, +} satisfies ElectronTheme.ElectronThemeShape); + +const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), + ), +); + +function makeTestLayer(input: { + readonly window: Electron.BrowserWindow; + readonly createCount: Ref.Ref; + readonly mainWindow: Ref.Ref>; +}) { + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), + main: Ref.get(input.mainWindow), + currentMainOrFirst: Ref.get(input.mainWindow), + focusedMainOrFirst: Ref.get(input.mainWindow), + setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), + clearMain: () => Ref.set(input.mainWindow, Option.none()), + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: (sync) => sync(input.window), + } satisfies ElectronWindow.ElectronWindowShape); + + return DesktopWindow.layer.pipe( + Layer.provide( + Layer.mergeAll( + desktopAssetsLayer, + desktopEnvironmentLayer, + desktopServerExposureLayer, + DesktopState.layer, + electronMenuLayer, + electronShellLayer, + electronThemeLayer, + electronWindowLayer, + ), + ), + ); +} + +describe("DesktopWindow", () => { + it.effect("does not open a development window until the backend is ready", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 0); + + yield* desktopWindow.handleBackendReady; + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); + assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts new file mode 100644 index 00000000000..8ebd1041c6b --- /dev/null +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -0,0 +1,368 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 type * as Electron from "electron"; + +import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; + +const TITLEBAR_HEIGHT = 40; +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; + +type WindowTitleBarOptions = Pick< + Electron.BrowserWindowConstructorOptions, + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" +>; + +type DesktopWindowRuntimeServices = + | DesktopEnvironment.DesktopEnvironment + | DesktopAssets.DesktopAssets + | DesktopServerExposure.DesktopServerExposure + | DesktopState.DesktopState + | ElectronMenu.ElectronMenu + | ElectronShell.ElectronShell + | ElectronTheme.ElectronTheme + | ElectronWindow.ElectronWindow; + +export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( + "DesktopWindowDevServerUrlMissingError", +)<{}> { + override get message() { + return "VITE_DEV_SERVER_URL is required in desktop development."; + } +} + +export type DesktopWindowError = + | DesktopWindowDevServerUrlMissingError + | ElectronWindow.ElectronWindowCreateError; + +export interface DesktopWindowShape { + readonly createMain: Effect.Effect; + readonly ensureMain: Effect.Effect; + readonly revealOrCreateMain: Effect.Effect; + readonly activate: Effect.Effect; + readonly createMainIfBackendReady: Effect.Effect; + readonly handleBackendReady: Effect.Effect; + readonly dispatchMenuAction: (action: string) => Effect.Effect; + readonly syncAppearance: Effect.Effect; +} + +export class DesktopWindow extends Context.Service()( + "t3/desktop/Window", +) {} + +const { logInfo: logWindowInfo, logWarning: logWindowWarning } = + DesktopObservability.makeComponentLogger("desktop-window"); + +function resolveDesktopDevServerUrl( + environment: DesktopEnvironment.DesktopEnvironmentShape, +): Effect.Effect { + return Option.match(environment.devServerUrl, { + onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), + onSome: (url) => Effect.succeed(url.href), + }); +} + +function getIconOption( + iconPaths: DesktopAssets.DesktopIconPaths, +): { icon: string } | Record { + if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = process.platform === "win32" ? "ico" : "png"; + return Option.match(iconPaths[ext], { + onNone: () => ({}), + onSome: (icon) => ({ icon }), + }); +} + +function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { + return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + +function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { + if (process.platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + } + + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, + }, + }; +} + +function syncWindowAppearance( + window: Electron.BrowserWindow, + shouldUseDarkColors: boolean, +): Effect.Effect { + return Effect.sync(() => { + if (window.isDestroyed()) { + return; + } + + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + if (typeof titleBarOverlay === "object") { + window.setTitleBarOverlay(titleBarOverlay); + } + }); +} + +type RevealSubscription = (listener: () => void) => void; + +function bindFirstRevealTrigger( + subscribers: readonly RevealSubscription[], + reveal: () => void, +): void { + let revealed = false; + const fire = () => { + if (revealed) return; + revealed = true; + reveal(); + }; + for (const subscribe of subscribers) { + subscribe(fire); + } +} + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const assets = yield* DesktopAssets.DesktopAssets; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const state = yield* DesktopState.DesktopState; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const createWindow = Effect.fn("desktop.window.createWindow")(function* ( + backendHttpUrl: URL, + ): Effect.fn.Return { + const iconPaths = yield* assets.iconPaths; + const iconOption = getIconOption(iconPaths); + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const window = yield* electronWindow.create({ + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + ...iconOption, + title: environment.displayName, + ...getWindowTitleBarOptions(shouldUseDarkColors), + webPreferences: { + preload: environment.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + window.webContents.on("context-menu", (event, params) => { + event.preventDefault(); + + const menuTemplate: Electron.MenuItemConstructorOptions[] = []; + + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { + menuTemplate.push({ + label: suggestion, + click: () => window.webContents.replaceMisspelling(suggestion), + }); + } + if (params.dictionarySuggestions.length === 0) { + menuTemplate.push({ label: "No suggestions", enabled: false }); + } + menuTemplate.push({ type: "separator" }); + } + + if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { + menuTemplate.push( + { + label: "Copy Link", + click: () => { + void runPromise(electronShell.copyText(params.linkURL)); + }, + }, + { type: "separator" }, + ); + } + + if (params.mediaType === "image") { + menuTemplate.push({ + label: "Copy Image", + click: () => window.webContents.copyImageAt(params.x, params.y), + }); + menuTemplate.push({ type: "separator" }); + } + + menuTemplate.push( + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { role: "selectAll", enabled: params.editFlags.canSelectAll }, + ); + + void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate })); + }); + + window.webContents.setWindowOpenHandler(({ url }) => { + if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { + void runPromise(electronShell.openExternal(url)); + } + return { action: "deny" }; + }); + + window.on("page-title-updated", (event) => { + event.preventDefault(); + window.setTitle(environment.displayName); + }); + window.webContents.on("did-finish-load", () => { + window.setTitle(environment.displayName); + }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame) { + return; + } + void runPromise( + logWindowWarning("main window failed to load", { + errorCode, + errorDescription, + url: validatedURL, + }), + ); + }, + ); + window.webContents.on("render-process-gone", (_event, details) => { + void runPromise( + logWindowWarning("main window render process gone", { + reason: details.reason, + exitCode: details.exitCode, + }), + ); + }); + + const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; + if (process.platform === "linux") { + revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); + } + bindFirstRevealTrigger(revealSubscribers, () => { + void runPromise(electronWindow.reveal(window)); + }); + + if (environment.isDevelopment) { + const devServerUrl = yield* resolveDesktopDevServerUrl(environment); + void window.loadURL(devServerUrl); + window.webContents.openDevTools({ mode: "detach" }); + } else { + void window.loadURL(backendHttpUrl.href); + } + + window.on("closed", () => { + void runPromise(electronWindow.clearMain(Option.some(window))); + }); + + return window; + }); + + const createMain = Effect.gen(function* () { + const backendConfig = yield* serverExposure.backendConfig; + const window = yield* createWindow(backendConfig.httpBaseUrl); + yield* electronWindow.setMain(window); + yield* logWindowInfo("main window created"); + return window; + }).pipe(Effect.withSpan("desktop.window.createMain")); + + const ensureMain = Effect.gen(function* () { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) { + return existingWindow.value; + } + return yield* createMain; + }).pipe(Effect.withSpan("desktop.window.ensureMain")); + + const revealOrCreateMain = Effect.gen(function* () { + const window = yield* ensureMain; + yield* electronWindow.reveal(window); + return window; + }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); + + const createMainIfBackendReady = Effect.gen(function* () { + const backendReady = yield* Ref.get(state.backendReady); + if (!backendReady) return; + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) return; + yield* createMain; + }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); + + return DesktopWindow.of({ + createMain, + ensureMain, + revealOrCreateMain, + activate: Effect.gen(function* () { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) { + yield* electronWindow.reveal(existingWindow.value); + } else { + yield* createMainIfBackendReady; + } + }).pipe(Effect.withSpan("desktop.window.activate")), + createMainIfBackendReady, + handleBackendReady: Effect.gen(function* () { + yield* Ref.set(state.backendReady, true); + yield* logWindowInfo("backend ready", { source: "http" }); + yield* createMainIfBackendReady; + }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), + dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { + yield* Effect.annotateCurrentSpan({ action }); + const existingWindow = yield* electronWindow.focusedMainOrFirst; + const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain; + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); + void runPromise(electronWindow.reveal(targetWindow)); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); + }), + syncAppearance: Effect.gen(function* () { + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + yield* electronWindow.syncAllAppearance((window) => + syncWindowAppearance(window, shouldUseDarkColors), + ); + }).pipe(Effect.withSpan("desktop.window.syncAppearance")), + }); +}); + +export const layer = Layer.effect(DesktopWindow, make); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 0ca5bcaa76a..ff3e4cd0f38 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "electron"], - "lib": ["ES2023", "DOM", "esnext.disposable"] + "lib": ["ESNext", "DOM", "esnext.disposable"] }, "include": ["src", "tsdown.config.ts"] } diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index f3ebc973253..53b00393439 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -4,7 +4,7 @@ const shared = { format: "cjs" as const, outDir: "dist-electron", sourcemap: true, - outExtensions: () => ({ js: ".js" }), + outExtensions: () => ({ js: ".cjs" }), }; export default defineConfig([ diff --git a/apps/marketing/public/apple-touch-icon.webp b/apps/marketing/public/apple-touch-icon.webp new file mode 100644 index 00000000000..fc990561902 Binary files /dev/null and b/apps/marketing/public/apple-touch-icon.webp differ diff --git a/apps/marketing/public/favicon-16x16.webp b/apps/marketing/public/favicon-16x16.webp new file mode 100644 index 00000000000..b09dc155aef Binary files /dev/null and b/apps/marketing/public/favicon-16x16.webp differ diff --git a/apps/marketing/public/favicon-32x32.webp b/apps/marketing/public/favicon-32x32.webp new file mode 100644 index 00000000000..72c9243de42 Binary files /dev/null and b/apps/marketing/public/favicon-32x32.webp differ diff --git a/apps/marketing/public/harnesses/claude-ai-icon.svg b/apps/marketing/public/harnesses/claude-ai-icon.svg new file mode 100644 index 00000000000..324389017b5 --- /dev/null +++ b/apps/marketing/public/harnesses/claude-ai-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/marketing/public/harnesses/cursor_light.svg b/apps/marketing/public/harnesses/cursor_light.svg new file mode 100644 index 00000000000..e61e0be3bfd --- /dev/null +++ b/apps/marketing/public/harnesses/cursor_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/marketing/public/harnesses/openai_dark.svg b/apps/marketing/public/harnesses/openai_dark.svg new file mode 100644 index 00000000000..b78a51db7bc --- /dev/null +++ b/apps/marketing/public/harnesses/openai_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/marketing/public/harnesses/opencode-dark.svg b/apps/marketing/public/harnesses/opencode-dark.svg new file mode 100644 index 00000000000..fc467bf8440 --- /dev/null +++ b/apps/marketing/public/harnesses/opencode-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/marketing/public/icon.webp b/apps/marketing/public/icon.webp new file mode 100644 index 00000000000..aa6826dd36d Binary files /dev/null and b/apps/marketing/public/icon.webp differ diff --git a/apps/marketing/public/pfps/BennettBuhner.webp b/apps/marketing/public/pfps/BennettBuhner.webp new file mode 100644 index 00000000000..39d5af923c5 Binary files /dev/null and b/apps/marketing/public/pfps/BennettBuhner.webp differ diff --git a/apps/marketing/public/pfps/DavidKPiano.webp b/apps/marketing/public/pfps/DavidKPiano.webp new file mode 100644 index 00000000000..d6b322bc2c1 Binary files /dev/null and b/apps/marketing/public/pfps/DavidKPiano.webp differ diff --git a/apps/marketing/public/pfps/Josikinz.webp b/apps/marketing/public/pfps/Josikinz.webp new file mode 100644 index 00000000000..68d5825222a Binary files /dev/null and b/apps/marketing/public/pfps/Josikinz.webp differ diff --git a/apps/marketing/public/pfps/Shay_Benshabtay.webp b/apps/marketing/public/pfps/Shay_Benshabtay.webp new file mode 100644 index 00000000000..c827f7504c4 Binary files /dev/null and b/apps/marketing/public/pfps/Shay_Benshabtay.webp differ diff --git a/apps/marketing/public/pfps/_winter_wonders.webp b/apps/marketing/public/pfps/_winter_wonders.webp new file mode 100644 index 00000000000..8acff2310aa Binary files /dev/null and b/apps/marketing/public/pfps/_winter_wonders.webp differ diff --git a/apps/marketing/public/pfps/aronprins.webp b/apps/marketing/public/pfps/aronprins.webp new file mode 100644 index 00000000000..90db37d28f2 Binary files /dev/null and b/apps/marketing/public/pfps/aronprins.webp differ diff --git a/apps/marketing/public/pfps/developedbyed.webp b/apps/marketing/public/pfps/developedbyed.webp new file mode 100644 index 00000000000..8f1dfbab6db Binary files /dev/null and b/apps/marketing/public/pfps/developedbyed.webp differ diff --git a/apps/marketing/public/pfps/ex0t1clol.webp b/apps/marketing/public/pfps/ex0t1clol.webp new file mode 100644 index 00000000000..05bd80e019a Binary files /dev/null and b/apps/marketing/public/pfps/ex0t1clol.webp differ diff --git a/apps/marketing/public/pfps/gnukeith.webp b/apps/marketing/public/pfps/gnukeith.webp new file mode 100644 index 00000000000..aa24d1996ff Binary files /dev/null and b/apps/marketing/public/pfps/gnukeith.webp differ diff --git a/apps/marketing/public/pfps/iamkaffe.webp b/apps/marketing/public/pfps/iamkaffe.webp new file mode 100644 index 00000000000..ded6ed2cd86 Binary files /dev/null and b/apps/marketing/public/pfps/iamkaffe.webp differ diff --git a/apps/marketing/public/pfps/jetpackjoe_.webp b/apps/marketing/public/pfps/jetpackjoe_.webp new file mode 100644 index 00000000000..da415598564 Binary files /dev/null and b/apps/marketing/public/pfps/jetpackjoe_.webp differ diff --git a/apps/marketing/public/pfps/kostyniuk00.webp b/apps/marketing/public/pfps/kostyniuk00.webp new file mode 100644 index 00000000000..c52f0b305b9 Binary files /dev/null and b/apps/marketing/public/pfps/kostyniuk00.webp differ diff --git a/apps/marketing/public/pfps/leodev.webp b/apps/marketing/public/pfps/leodev.webp new file mode 100644 index 00000000000..91a47d5de94 Binary files /dev/null and b/apps/marketing/public/pfps/leodev.webp differ diff --git a/apps/marketing/public/pfps/mil000.webp b/apps/marketing/public/pfps/mil000.webp new file mode 100644 index 00000000000..37d784b752c Binary files /dev/null and b/apps/marketing/public/pfps/mil000.webp differ diff --git a/apps/marketing/public/pfps/peculiarnewbie.webp b/apps/marketing/public/pfps/peculiarnewbie.webp new file mode 100644 index 00000000000..74be17008b2 Binary files /dev/null and b/apps/marketing/public/pfps/peculiarnewbie.webp differ diff --git a/apps/marketing/public/pfps/pocarles.webp b/apps/marketing/public/pfps/pocarles.webp new file mode 100644 index 00000000000..f259df83124 Binary files /dev/null and b/apps/marketing/public/pfps/pocarles.webp differ diff --git a/apps/marketing/public/pfps/tannerlinsley.webp b/apps/marketing/public/pfps/tannerlinsley.webp new file mode 100644 index 00000000000..8ea61b5dd46 Binary files /dev/null and b/apps/marketing/public/pfps/tannerlinsley.webp differ diff --git a/apps/marketing/public/pfps/teja2495.webp b/apps/marketing/public/pfps/teja2495.webp new file mode 100644 index 00000000000..457901988d4 Binary files /dev/null and b/apps/marketing/public/pfps/teja2495.webp differ diff --git a/apps/marketing/public/pfps/uwunetes.webp b/apps/marketing/public/pfps/uwunetes.webp new file mode 100644 index 00000000000..f15366aa66f Binary files /dev/null and b/apps/marketing/public/pfps/uwunetes.webp differ diff --git a/apps/marketing/public/screenshot.jpeg b/apps/marketing/public/screenshot.jpeg deleted file mode 100644 index 0844b50ad5b..00000000000 Binary files a/apps/marketing/public/screenshot.jpeg and /dev/null differ diff --git a/apps/marketing/public/screenshot.webp b/apps/marketing/public/screenshot.webp new file mode 100644 index 00000000000..b9b0703dca0 Binary files /dev/null and b/apps/marketing/public/screenshot.webp differ diff --git a/apps/marketing/public/updated-screenshot.webp b/apps/marketing/public/updated-screenshot.webp new file mode 100644 index 00000000000..c245ddb64a1 Binary files /dev/null and b/apps/marketing/public/updated-screenshot.webp differ diff --git a/apps/marketing/src/components/TuxIcon.astro b/apps/marketing/src/components/TuxIcon.astro new file mode 100644 index 00000000000..a6f1b4902c6 --- /dev/null +++ b/apps/marketing/src/components/TuxIcon.astro @@ -0,0 +1,53 @@ +--- +interface Props { + class?: string; + idPrefix: string; +} + +const { class: className, idPrefix } = Astro.props; +--- + + diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index b4fa945e25a..e60637cbfd1 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -6,7 +6,7 @@ interface Props { const { title = "T3 Code", - description = "T3 Code — The best way to code with AI.", + description = "T3 Code — The open-source control plane for coding agents.", } = Astro.props; --- @@ -18,7 +18,7 @@ const { @@ -30,18 +30,25 @@ const {
@@ -49,23 +56,54 @@ const {
- © {new Date().getFullYear()} T3 Tools Inc -
+ + diff --git a/apps/marketing/src/lib/tweets.ts b/apps/marketing/src/lib/tweets.ts new file mode 100644 index 00000000000..7d6be71c5f1 --- /dev/null +++ b/apps/marketing/src/lib/tweets.ts @@ -0,0 +1,123 @@ +export type Tweet = { + handle: string; + content: string; + excerpt?: string; + link: string; +}; + +export const tweets = [ + { + handle: "Shay_Benshabtay", + content: + "T3 code is not perfect, but it's damn good. Open and fun , I can make my own fork for my uses and use the great tooling with my own needs.", + link: "https://x.com/Shay_Benshabtay/status/2054668503857156326", + }, + { + handle: "teja2495", + content: + "I’ve completely switched to T3 Code for all my workflows. I just switch between different subscriptions and harnesses depending on what I need.", + excerpt: "I’ve completely switched to T3 Code for all my workflows.", + link: "https://x.com/teja2495/status/2052420254991581623", + }, + { + handle: "developedbyed", + content: "might say alpha but T3 code is pretty awesome, really fast too", + link: "https://x.com/developedbyed/status/2030627970532921605", + }, + { + handle: "tannerlinsley", + content: + "The minute T3 Code supports Claude Code, it could potentially become my daily driver. There's something special there.", + link: "https://x.com/tannerlinsley/status/2031102771529920966", + }, + { + handle: "aronprins", + content: + "I already loved T3 Code by @theo and @jullerino, but their Connections implementation is next level epic and for this they should both earn maximum repect 🔥🫡", + excerpt: "I already loved T3 Code, but their Connections implementation is next level epic.", + link: "https://x.com/aronprins/status/2045102518196183109", + }, + { + handle: "BennettBuhner", + content: + "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere! The app is great but the website is even greater, so instead of needing to SSH into a machine, T3 IS my SSH!", + excerpt: + "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere.", + link: "https://x.com/BennettBuhner/status/2054667115697754387", + }, + { + handle: "ex0t1clol", + content: "T3 Code is proof electron apps don't have to suck", + link: "https://x.com/ex0t1clol/status/2054666870008021197", + }, + { + handle: "Josikinz", + content: + "T3 code is better because it’s like if the codex Mac app didn’t make my computer run like shit 😋", + link: "https://x.com/Josikinz/status/2030367951694745870", + }, + { + handle: "jetpackjoe_", + content: "T3 code is pretty alright I guess", + link: "https://x.com/jetpackjoe_/status/2054666792933404959", + }, + { + handle: "mil000", + content: "T3 code saved my relationship!", + link: "https://x.com/mil000/status/2030120041451246071", + }, + { + handle: "_winter_wonders", + content: "Heartbreaking: AI-hater has to admit T3 Code is really good.", + link: "https://x.com/_winter_wonders/status/2052350198764970434", + }, + { + handle: "kostyniuk00", + content: + "I was not expecting a year ago, that my anti-AI colleagues would thank me a year later, for persuading them to try T3 Code Beta, that really helped them with organizing their workflows. \n\nFantastic product by @jullerino and @theo. Go try it out if you haven’t yet!", + excerpt: + "My anti-AI colleagues thanked me for persuading them to try T3 Code Beta. Fantastic product.", + link: "https://x.com/kostyniuk00/status/2052041388179468521", + }, + { + handle: "gnukeith", + content: + "I tried T3 Code it actually fixed issues that Opus couldn’t solve in Claude Code + Theo has replied with “UwU” under my post. Dario has not.", + link: "https://x.com/gnukeith/status/2054670073579630730", + }, + { + handle: "peculiarnewbie", + content: "T3 Code is iOS and other harnesses are androids", + link: "https://x.com/peculiarnewbie/status/2054671685027233827", + }, + { + handle: "leodev", + content: + "T3 Code is probably the first coding gui that didn't suck performance wise and had a smart team behind it.", + link: "https://x.com/leodev/status/2054679746353537042", + }, + { + handle: "pocarles", + content: + "Only using Codex and T3 now.\n\nI thought all AI coding harnesses had roughly the same impact. Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", + excerpt: + "Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", + link: "https://x.com/pocarles/status/2054673964274758046", + }, + { + handle: "DavidKPiano", + content: "It's like Claude Code if they didn't vibe-code the entire thing", + link: "https://x.com/DavidKPiano/status/2054682983504719930", + }, + { + handle: "iamkaffe", + content: "T3Code was the first one to truly care about Linux users.", + link: "https://x.com/iamkaffe/status/2054675539311411280", + }, + { + handle: "uwunetes", + content: + "claude code make me go *whine whine whine* and t3 code make me go woof woof awooooo!!!", + link: "https://x.com/uwunetes/status/2054683356022120640", + }, +] satisfies Tweet[]; diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 54454d40264..b9e0e0181e4 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -4,6 +4,7 @@ import { RELEASES_URL } from "../lib/releases"; --- +

Download T3 Code

Loading latest release… @@ -64,6 +65,7 @@ import { RELEASES_URL } from "../lib/releases"; GitHub releases page

+
diff --git a/apps/marketing/tweets.md b/apps/marketing/tweets.md new file mode 100644 index 00000000000..92a087d8018 --- /dev/null +++ b/apps/marketing/tweets.md @@ -0,0 +1,19 @@ +https://x.com/Shay_Benshabtay/status/2054668503857156326 +https://x.com/teja2495/status/2052420254991581623 +https://x.com/developedbyed/status/2030627970532921605 +https://x.com/tannerlinsley/status/2031102771529920966 +https://x.com/aronprins/status/2045102518196183109 +https://x.com/BennettBuhner/status/2054667115697754387 +https://x.com/ex0t1clol/status/2054666870008021197 +https://x.com/Josikinz/status/2030367951694745870 +https://x.com/jetpackjoe_/status/2054666792933404959 +https://x.com/mil000/status/2030120041451246071 +https://x.com/_winter_wonders/status/2052350198764970434 +https://x.com/kostyniuk00/status/2052041388179468521 +https://x.com/gnukeith/status/2054670073579630730 +https://x.com/peculiarnewbie/status/2054671685027233827 +https://x.com/leodev/status/2054679746353537042 +https://x.com/pocarles/status/2054673964274758046 +https://x.com/DavidKPiano/status/2054682983504719930 +https://x.com/iamkaffe/status/2054675539311411280 +https://x.com/uwunetes/status/2054683356022120640 diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6daa43ca42a..837c32fc4fd 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -1,33 +1,30 @@ +// @effect-diagnostics nodeBuiltinImport:off import { execFileSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ApprovalRequestId, - ProviderKind, + CodexSettings, + ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, } from "@t3tools/contracts"; -import { - Effect, - Exit, - FileSystem, - Layer, - ManagedRuntime, - Option, - Path, - Ref, - Schedule, - Schema, - Scope, - Stream, -} from "effect"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; -import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; -import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; -import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts"; -import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -36,13 +33,16 @@ import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; -import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; +import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; -import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; -import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; +import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../src/provider/Layers/ProviderEventLoggers.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; @@ -58,6 +58,7 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; +import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -72,6 +73,12 @@ import { import { deriveServerPaths, ServerConfig } from "../src/config.ts"; import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; +import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; +import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; + +const decodeCodexSettings = Schema.decodeEffect(CodexSettings); function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -213,7 +220,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: ProviderKind; + readonly provider?: ProviderDriverKind; readonly realCodex?: boolean; } @@ -224,7 +231,7 @@ export const makeOrchestrationIntegrationHarness = ( const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; - const provider = options?.provider ?? "codex"; + const provider = options?.provider ?? ProviderDriverKind.make("codex"); const useRealCodex = options?.realCodex === true; const adapterHarness = useRealCodex ? null @@ -232,13 +239,10 @@ export const makeOrchestrationIntegrationHarness = ( provider, }); const fakeRegistry = adapterHarness - ? Layer.succeed(ProviderAdapterRegistry, { - getByProvider: (resolvedProvider) => - resolvedProvider === adapterHarness.provider - ? Effect.succeed(adapterHarness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), - listProviders: () => Effect.succeed([adapterHarness.provider]), - } as typeof ProviderAdapterRegistry.Service) + ? Layer.succeed( + ProviderAdapterRegistry, + makeAdapterRegistryMock({ [adapterHarness.provider]: adapterHarness.adapter }), + ) : null; const rootDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-orchestration-integration-", @@ -263,34 +267,33 @@ export const makeOrchestrationIntegrationHarness = ( const realCodexRegistry = Layer.effect( ProviderAdapterRegistry, Effect.gen(function* () { - const codexAdapter = yield* CodexAdapter; - return { - getByProvider: (resolvedProvider) => - resolvedProvider === "codex" - ? Effect.succeed(codexAdapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), - listProviders: () => Effect.succeed(["codex"] as const), - } as typeof ProviderAdapterRegistry.Service; + const codexSettings = yield* decodeCodexSettings({}); + const codexAdapter = yield* makeCodexAdapter(codexSettings); + return makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codexAdapter, + }); }), ).pipe( - Layer.provide(makeCodexAdapterLive()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); + const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); const providerLayer = useRealCodex ? makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(realCodexRegistry), Layer.provide(AnalyticsService.layerTest), + Layer.provide(providerEventLoggersLayer), ) : makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(fakeRegistry!), Layer.provide(AnalyticsService.layerTest), + Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -306,31 +309,34 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitCoreLayer = Layer.succeed(GitCore, { - renameBranch: (input: Parameters[0]) => - Effect.succeed({ branch: input.newBranch }), - } as unknown as GitCoreShape); + const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.succeed({ branch: input.newBranch }), + }); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge( - Layer.succeed(GitStatusBroadcaster, { + Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -341,16 +347,23 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), ), ), Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsProcess.layer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), + Layer.provideMerge( + Layer.succeed(ThreadDeletionReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), ); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index f4973953078..28873c51f97 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -1,5 +1,3 @@ -import { randomUUID } from "node:crypto"; - import { ApprovalRequestId, EventId, @@ -10,9 +8,12 @@ import { ProviderTurnStartResult, ThreadId, TurnId, - ProviderKind, + ProviderDriverKind, } from "@t3tools/contracts"; -import { Effect, Queue, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Stream from "effect/Stream"; import { ProviderAdapterSessionNotFoundError, @@ -36,7 +37,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -178,7 +179,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -198,15 +199,15 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: ProviderKind; + readonly provider?: ProviderDriverKind; } function nowIso(): string { - return new Date().toISOString(); + return "2026-01-01T00:00:00.000Z"; } function sessionNotFound( - provider: ProviderKind, + provider: ProviderDriverKind, threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -216,7 +217,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: ProviderKind, + provider: ProviderDriverKind, threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); @@ -224,7 +225,7 @@ function missingSessionEffect( export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => Effect.gen(function* () { - const provider = options?.provider ?? "codex"; + const provider = options?.provider ?? ProviderDriverKind.make("codex"); const runtimeEvents = yield* Queue.unbounded(); let sessionCount = 0; const sessions = new Map(); @@ -257,6 +258,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const session: ProviderSession = { provider, + ...(input.providerInstanceId !== undefined + ? { providerInstanceId: input.providerInstanceId } + : {}), status: "ready", runtimeMode: input.runtimeMode, threadId, @@ -305,10 +309,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter for (const fixtureEvent of response.events) { const rawEvent: Record = { ...(fixtureEvent as Record), - eventId: randomUUID(), + eventId: yield* Random.nextUUIDv4, provider, sessionId: RuntimeSessionId.make(String(input.threadId)), - createdAt: nowIso(), }; rawEvent.threadId = state.snapshot.threadId; if (Object.hasOwn(rawEvent, "turnId")) { @@ -363,7 +366,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter if (deferredTurnCompletedEvents.length === 0) { yield* emit({ type: "turn.completed", - eventId: EventId.make(randomUUID()), + eventId: EventId.make(yield* Random.nextUUIDv4), provider, createdAt: nowIso(), threadId: state.snapshot.threadId, diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index 14a45518c3c..e1258c4cc62 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -1,7 +1,7 @@ -import { EventId, RuntimeRequestId } from "@t3tools/contracts"; +import { EventId, ProviderDriverKind, RuntimeRequestId } from "@t3tools/contracts"; import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts"; -const PROVIDER = "codex" as const; +const PROVIDER = ProviderDriverKind.make("codex"); const SESSION_ID = "fixture-session"; const THREAD_ID = "fixture-thread"; const TURN_ID = "fixture-turn"; diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index a7f845672ca..e79897c740e 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -1,20 +1,27 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import path from "node:path"; import { ApprovalRequestId, CommandId, + defaultInstanceIdForDriver, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, EventId, MessageId, ProjectId, - ProviderKind, + ProviderDriverKind, ThreadId, ModelSelection, + ProviderInstanceId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Effect, Option, Schema } from "effect"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; import { @@ -39,10 +46,12 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.make("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -type IntegrationProvider = ProviderKind; +type IntegrationProvider = ProviderDriverKind; +const CODEX_PROVIDER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_PROVIDER = ProviderDriverKind.make("claudeAgent"); function nowIso() { - return new Date().toISOString(); + return "2026-05-01T00:00:00.000Z"; } class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass()( @@ -59,14 +68,14 @@ function waitForSync( timeoutMs = 10_000, ): Effect.Effect { return Effect.gen(function* () { - const deadline = Date.now() + timeoutMs; + const deadline = (yield* Clock.currentTimeMillis) + timeoutMs; while (true) { const value = read(); if (predicate(value)) { return value; } - if (Date.now() >= deadline) { + if ((yield* Clock.currentTimeMillis) >= deadline) { return yield* Effect.die(new IntegrationWaitTimeoutError({ description })); } yield* Effect.sleep(10); @@ -74,7 +83,11 @@ function waitForSync( }); } -function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") { +function runtimeBase( + eventId: string, + createdAt: string, + provider: IntegrationProvider = CODEX_PROVIDER, +) { return { eventId: asEventId(eventId), provider, @@ -84,7 +97,7 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr function withHarness( use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, - provider: IntegrationProvider = "codex", + provider: IntegrationProvider = CODEX_PROVIDER, ) { return Effect.acquireUseRelease( makeOrchestrationIntegrationHarness({ provider }), @@ -97,7 +110,7 @@ function withRealCodexHarness( use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, ) { return Effect.acquireUseRelease( - makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }), + makeOrchestrationIntegrationHarness({ provider: CODEX_PROVIDER, realCodex: true }), use, (harness) => harness.dispose, ).pipe(Effect.provide(NodeServices.layer)); @@ -106,8 +119,9 @@ function withRealCodexHarness( const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); - const provider = harness.adapterHarness?.provider ?? "codex"; - const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; + const provider = harness.adapterHarness?.provider ?? CODEX_PROVIDER; + const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + const instanceId = defaultInstanceIdForDriver(provider); yield* harness.engine.dispatch({ type: "project.create", @@ -116,7 +130,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => title: "Integration Project", workspaceRoot: harness.workspaceDir, defaultModelSelection: { - provider, + instanceId, model: defaultModel, }, createdAt, @@ -129,7 +143,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Thread", modelSelection: { - provider, + instanceId, model: defaultModel, }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -146,6 +160,7 @@ const startTurn = (input: { readonly messageId: string; readonly text: string; readonly modelSelection?: ModelSelection; + readonly createdAt?: string; }) => input.harness.engine.dispatch({ type: "thread.turn.start", @@ -164,7 +179,7 @@ const startTurn = (input: { : {}), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - createdAt: nowIso(), + createdAt: input.createdAt ?? nowIso(), }); it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + git", () => @@ -265,7 +280,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( title: "Integration Project", workspaceRoot: harness.workspaceDir, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", }, createdAt, @@ -278,7 +293,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( projectId: PROJECT_ID, title: "Integration Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -496,6 +511,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1), toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), fallbackFromToHead: false, + ignoreWhitespace: false, }); assert.equal(incrementalDiff.includes("README.md"), true); @@ -504,6 +520,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0), toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), fallbackFromToHead: false, + ignoreWhitespace: false, }); assert.equal(fullDiff.includes("README.md"), true); @@ -743,6 +760,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git commandId: "cmd-turn-start-revert-1", messageId: "msg-user-revert-1", text: "First edit", + createdAt: "2026-02-24T10:04:59.900Z", }); yield* harness.waitForThread( @@ -801,6 +819,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git commandId: "cmd-turn-start-revert-2", messageId: "msg-user-revert-2", text: "Second edit", + createdAt: "2026-02-24T10:05:00.900Z", }); yield* harness.waitForThread( @@ -912,20 +931,32 @@ it.live("starts a claudeAgent session on first turn when provider is requested", events: [ { type: "turn.started", - ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-start-1", + "2026-02-24T10:10:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-start-2", + "2026-02-24T10:10:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Claude first turn.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-start-3", + "2026-02-24T10:10:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -939,7 +970,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", messageId: "msg-user-claude-initial", text: "Use Claude", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -955,7 +986,7 @@ it.live("starts a claudeAgent session on first turn when provider is requested", ); assert.equal(thread.session?.providerName, "claudeAgent"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -969,20 +1000,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re events: [ { type: "turn.started", - ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-1", + "2026-02-24T10:11:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-2", + "2026-02-24T10:11:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Turn before restart.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-3", + "2026-02-24T10:11:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -996,7 +1039,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re messageId: "msg-user-claude-recover-1", text: "Before restart", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1018,20 +1061,32 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re events: [ { type: "turn.started", - ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-4", + "2026-02-24T10:11:01.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-5", + "2026-02-24T10:11:01.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Turn after restart.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-recover-6", + "2026-02-24T10:11:01.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1063,7 +1118,7 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re assert.equal(recoveredThread.session?.providerName, "claudeAgent"); assert.equal(recoveredThread.session?.threadId, "thread-1"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1077,13 +1132,21 @@ it.live("forwards claudeAgent approval responses to the provider session", () => events: [ { type: "turn.started", - ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-approval-1", + "2026-02-24T10:12:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "approval.requested", - ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-approval-2", + "2026-02-24T10:12:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, requestId: APPROVAL_REQUEST_ID, @@ -1092,7 +1155,11 @@ it.live("forwards claudeAgent approval responses to the provider session", () => }, { type: "turn.completed", - ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-approval-3", + "2026-02-24T10:12:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1106,7 +1173,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => messageId: "msg-user-claude-approval", text: "Need approval", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1137,7 +1204,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => ); assert.equal(approvalResponses[0]?.decision, "accept"); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1151,20 +1218,32 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => events: [ { type: "turn.started", - ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-interrupt-1", + "2026-02-24T10:13:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-interrupt-2", + "2026-02-24T10:13:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Long running output.\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-interrupt-3", + "2026-02-24T10:13:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1178,7 +1257,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => messageId: "msg-user-claude-interrupt", text: "Start long turn", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1206,7 +1285,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => ); assert.equal(interruptCalls.length, 1); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); @@ -1220,20 +1299,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", events: [ { type: "turn.started", - ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-1", + "2026-02-24T10:14:00.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-2", + "2026-02-24T10:14:00.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "README -> v2\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-3", + "2026-02-24T10:14:00.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1251,7 +1342,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", messageId: "msg-user-claude-revert-1", text: "First Claude edit", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", }, }); @@ -1266,20 +1357,32 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", events: [ { type: "turn.started", - ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-4", + "2026-02-24T10:14:01.000Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", - ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-5", + "2026-02-24T10:14:01.050Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "README -> v3\n", }, { type: "turn.completed", - ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), + ...runtimeBase( + "evt-claude-revert-6", + "2026-02-24T10:14:01.100Z", + CLAUDE_AGENT_PROVIDER, + ), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1330,6 +1433,6 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", ); assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); }), - "claudeAgent", + CLAUDE_AGENT_PROVIDER, ), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 89cf6ac153d..57e93c5acdd 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,13 +1,22 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts"; -import { ThreadId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; -import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../src/provider/Layers/ProviderEventLoggers.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { ProviderService, @@ -29,6 +38,8 @@ import { codexTurnTextFixture, } from "./fixtures/providerRuntime.ts"; +const codexInstanceId = ProviderInstanceId.make("codex"); + const makeWorkspaceDirectory = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; @@ -47,13 +58,9 @@ const makeIntegrationFixture = Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; const harness = yield* makeTestProviderAdapterHarness(); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(harness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), @@ -64,6 +71,7 @@ const makeIntegrationFixture = Effect.gen(function* () { Layer.succeed(ProviderAdapterRegistry, registry), ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ).pipe(Layer.provide(SqlitePersistenceMemory)); const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); @@ -124,7 +132,8 @@ it.live("replays typed runtime fixture events", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), { threadId: ThreadId.make("thread-integration-typed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); @@ -142,6 +151,10 @@ it.live("replays typed runtime fixture events", () => observedEvents.map((event) => event.type), codexTurnTextFixture.map((event) => event.type), ); + assert.deepEqual( + observedEvents.map((event) => event.providerInstanceId), + codexTurnTextFixture.map(() => codexInstanceId), + ); }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -156,7 +169,8 @@ it.live("replays file-changing fixture turn events", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), { threadId: ThreadId.make("thread-integration-tools"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); @@ -192,7 +206,8 @@ it.live("runs multi-turn tool/approval flow", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), { threadId: ThreadId.make("thread-integration-multi"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); @@ -243,7 +258,8 @@ it.live("rolls back provider conversation state only", () => const provider = yield* ProviderService; const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), { threadId: ThreadId.make("thread-integration-rollback"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: fixture.cwd, runtimeMode: "full-access", }); diff --git a/apps/server/package.json b/apps/server/package.json index 950079a4dc1..b8e6f482a28 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.17", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", @@ -15,31 +15,35 @@ ], "type": "module", "scripts": { - "dev": "bun run src/bin.ts", + "dev": "node --watch src/bin.ts", "build": "node scripts/cli.ts build", + "build:bundle": "tsdown", "start": "node dist/bin.mjs", - "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", + "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@pierre/diffs": "^1.1.0-beta.16", + "@opencode-ai/sdk": "^1.3.15", + "@pierre/diffs": "catalog:", "effect": "catalog:", - "node-pty": "^1.1.0", - "open": "^10.1.0" + "node-pty": "^1.1.0" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.111", "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "effect-acp": "workspace:*", + "effect-codex-app-server": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts new file mode 100644 index 00000000000..e704b8d8a25 --- /dev/null +++ b/apps/server/scripts/acp-mock-agent.ts @@ -0,0 +1,591 @@ +#!/usr/bin/env bun +// @effect-diagnostics nodeBuiltinImport:off +import { appendFileSync } from "node:fs"; + +import * as Effect from "effect/Effect"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; + +import * as EffectAcpAgent from "effect-acp/agent"; +import * as AcpError from "effect-acp/errors"; +import type * as AcpSchema from "effect-acp/schema"; + +const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; +const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; +const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const emitInterleavedAssistantToolCalls = + process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; +const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; +const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; +const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; +const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; +const sessionId = "mock-session-1"; + +let currentModeId = "ask"; +let currentModelId = "default"; +let parameterizedModelPicker = false; +let currentReasoning = "medium"; +let currentContext = "272k"; +let currentFast = false; +const cancelledSessions = new Set(); + +function logExit(reason: string): void { + if (!exitLogPath) { + return; + } + appendFileSync(exitLogPath, `${reason}\n`, "utf8"); +} + +process.once("SIGTERM", () => { + logExit("SIGTERM"); + process.exit(0); +}); + +process.once("SIGINT", () => { + logExit("SIGINT"); + process.exit(0); +}); + +process.once("exit", (code) => { + logExit(`exit:${code}`); +}); + +function configOptions(): ReadonlyArray { + if (parameterizedModelPicker) { + const baseOptions: Array = [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: currentModeId, + options: availableModes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "gpt-5.4", name: "GPT-5.4" }, + { value: "claude-opus-4-6", name: "Opus 4.6" }, + ], + }, + ]; + + switch (currentModelId) { + case "gpt-5.4": + return [ + ...baseOptions, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: currentReasoning, + options: [ + { value: "none", name: "None" }, + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "extra-high", name: "Extra High" }, + ], + }, + { + id: "context", + name: "Context", + category: "model_config", + type: "select", + currentValue: currentContext, + options: [ + { value: "272k", name: "272K" }, + { value: "1m", name: "1M" }, + ], + }, + { + id: "fast", + name: "Fast", + category: "model_config", + type: "select", + currentValue: String(currentFast), + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "Fast" }, + ], + }, + ]; + case "composer-2": + return [ + ...baseOptions, + { + id: "fast", + name: "Fast", + category: "model_config", + type: "select", + currentValue: String(currentFast), + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "Fast" }, + ], + }, + ]; + case "claude-opus-4-6": + return [ + ...baseOptions, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: currentReasoning, + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "model_config", + type: "boolean", + currentValue: true, + }, + ]; + default: + return baseOptions; + } + } + + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select" as const, + currentValue: currentModelId, + options: [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "composer-2[fast=true]", name: "Composer 2 Fast" }, + { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }, + ], + }, + ]; +} + +const availableModes: ReadonlyArray = [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, +]; + +function modeState(): AcpSchema.SessionModeState { + return { + currentModeId, + availableModes, + }; +} + +const program = Effect.gen(function* () { + const agent = yield* EffectAcpAgent.AcpAgent; + + yield* agent.handleInitialize((request) => + Effect.sync(() => { + parameterizedModelPicker = + request.clientCapabilities?._meta?.parameterizedModelPicker === true; + return { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }; + }), + ); + + yield* agent.handleAuthenticate(() => Effect.succeed({})); + + yield* agent.handleCreateSession(() => + Effect.succeed({ + sessionId, + modes: modeState(), + configOptions: configOptions(), + }), + ); + + yield* agent.handleLoadSession((request) => + agent.client + .sessionUpdate({ + sessionId: String(request.sessionId ?? sessionId), + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "replay" }, + }, + }) + .pipe( + Effect.as({ + modes: modeState(), + configOptions: configOptions(), + }), + ), + ); + + yield* agent.handleSetSessionConfigOption((request) => + Effect.gen(function* () { + if (exitOnSetConfigOption) { + return yield* Effect.sync(() => { + process.exit(7); + }); + } + if (failSetConfigOption) { + return yield* AcpError.AcpRequestError.invalidParams( + "Mock invalid params for session/set_config_option", + { + method: "session/set_config_option", + params: request, + }, + ); + } + if (request.configId === "mode" && typeof request.value === "string") { + currentModeId = request.value; + } + if (request.configId === "model" && typeof request.value === "string") { + currentModelId = request.value; + } + if (request.configId === "reasoning" && typeof request.value === "string") { + currentReasoning = request.value; + } + if (request.configId === "context" && typeof request.value === "string") { + currentContext = request.value; + } + if (request.configId === "fast") { + currentFast = request.value === true || request.value === "true"; + } + return { + configOptions: configOptions(), + }; + }), + ); + + yield* agent.handleCancel(({ sessionId }) => + Effect.sync(() => { + cancelledSessions.add(String(sessionId ?? "mock-session-1")); + }), + ); + + yield* agent.handlePrompt((request) => + Effect.gen(function* () { + const requestedSessionId = String(request.sessionId ?? sessionId); + + if (emitInterleavedAssistantToolCalls) { + const toolCallId = "tool-call-1"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "before tool" }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + command: ["echo", "hello"], + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "hello", + stderr: "", + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "after tool" }, + }, + }); + + return { stopReason: "end_turn" }; + } + + if (emitToolCalls) { + const toolCallId = "tool-call-1"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + command: ["cat", "server/package.json"], + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + }); + + const permission = yield* agent.client.requestPermission({ + sessionId: requestedSessionId, + toolCall: { + toolCallId, + title: "`cat server/package.json`", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Not in allowlist: cat server/package.json", + }, + }, + ], + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }); + + const cancelled = + cancelledSessions.delete(requestedSessionId) || + permission.outcome.outcome === "cancelled"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + title: "Terminal", + kind: "execute", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: '{ "name": "t3" }', + stderr: "", + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + }); + + return { stopReason: cancelled ? "cancelled" : "end_turn" }; + } + + if (emitGenericToolPlaceholders) { + const toolCallId = "tool-call-generic-1"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Read File", + kind: "read", + status: "pending", + rawInput: {}, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + content: "package.json\n", + }, + }, + }); + + return { stopReason: "end_turn" }; + } + + if (emitAskQuestion) { + yield* agent.client.extRequest("cursor/ask_question", { + toolCallId: "ask-question-tool-call-1", + title: "Question", + questions: [ + { + id: "scope", + prompt: "Which scope?", + options: [ + { id: "workspace", label: "Workspace" }, + { id: "session", label: "Session" }, + ], + }, + ], + }); + + return { stopReason: "end_turn" }; + } + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Inspect mock ACP state", + priority: "high", + status: "completed", + }, + { + content: "Implement the requested change", + priority: "high", + status: "in_progress", + }, + ], + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: promptResponseText ?? "hello from mock" }, + }, + }); + + return { stopReason: "end_turn" }; + }), + ); + + yield* agent.handleUnknownExtRequest((method, params) => { + if (method !== "session/mode/set") { + return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); + } + + const nextModeId = + typeof params === "object" && + params !== null && + "modeId" in params && + typeof params.modeId === "string" + ? params.modeId + : typeof params === "object" && + params !== null && + "mode" in params && + typeof params.mode === "string" + ? params.mode + : undefined; + const requestedSessionId = + typeof params === "object" && + params !== null && + "sessionId" in params && + typeof params.sessionId === "string" + ? params.sessionId + : sessionId; + + if (typeof nextModeId === "string" && nextModeId.trim()) { + currentModeId = nextModeId.trim(); + return agent.client + .sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId, + }, + }) + .pipe(Effect.as({})); + } + + return Effect.succeed({}); + }); + + return yield* Effect.never; +}).pipe( + Effect.provide( + EffectAcpAgent.layerStdio( + requestLogPath + ? { + logIncoming: true, + logger: (event) => { + if (event.direction !== "incoming" || event.stage !== "raw") { + return Effect.void; + } + if (typeof event.payload !== "string") { + return Effect.void; + } + const payload = event.payload; + return Effect.sync(() => { + appendFileSync( + requestLogPath, + payload.endsWith("\n") ? payload : `${payload}\n`, + "utf8", + ); + }); + }, + } + : {}, + ), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), +); + +NodeRuntime.runMain(program); diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 299da67faba..c7f40de42d7 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -1,8 +1,13 @@ #!/usr/bin/env node - import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Data, Effect, FileSystem, Logger, Option, Path } from "effect"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -11,6 +16,7 @@ import { PUBLISH_ICON_OVERRIDES, } from "../../../scripts/lib/brand-assets.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import rootPackageJson from "../../../package.json" with { type: "json" }; import serverPackageJson from "../package.json" with { type: "json" }; @@ -30,6 +36,9 @@ interface PackageJson { overrides: Record; } +const PackageJsonPrettyJson = fromJsonStringPretty(Schema.Unknown); +const encodePackageJson = Schema.encodeEffect(PackageJsonPrettyJson); + class CliError extends Data.TaggedError("CliError")<{ readonly message: string; readonly cause?: unknown; @@ -147,13 +156,13 @@ const buildCmd = Command.make( yield* Effect.log("[cli] Running tsdown..."); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(process.execPath, ["--run", "build:bundle"], { cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + // Windows needs shell mode to resolve `.cmd` shims on PATH. shell: process.platform === "win32", - })`bun tsdown`, + }), ); const webDist = path.join(repoRoot, "apps/web/dist"); @@ -203,10 +212,8 @@ const publishCmd = Command.make( } yield* Effect.acquireUseRelease( - // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts + // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts Effect.gen(function* () { - // Resolve catalog dependencies before any file mutations. If this throws, - // acquire fails and no release hook runs, so filesystem must still be untouched. const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); const pkg: PackageJson = { name: serverPackageJson.name, @@ -216,25 +223,23 @@ const publishCmd = Command.make( version, engines: serverPackageJson.engines, files: serverPackageJson.files, - dependencies: serverPackageJson.dependencies, - overrides: rootPackageJson.overrides, + dependencies: resolveCatalogDependencies( + serverPackageJson.dependencies, + rootPackageJson.workspaces.catalog, + "apps/server", + ), + overrides: resolveCatalogDependencies( + rootPackageJson.overrides, + rootPackageJson.workspaces.catalog, + "apps/server", + ), }; - pkg.dependencies = resolveCatalogDependencies( - pkg.dependencies, - rootPackageJson.workspaces.catalog, - "apps/server dependencies", - ); - pkg.overrides = resolveCatalogDependencies( - pkg.overrides, - rootPackageJson.workspaces.catalog, - "root overrides", - ); - const original = yield* fs.readFileString(packageJsonPath); + const packageJsonString = yield* encodePackageJson(pkg); yield* fs.writeFileString(backupPath, original); - yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); - yield* Effect.log("[cli] Resolved package.json for publish"); + yield* fs.writeFileString(packageJsonPath, `${packageJsonString}\n`); + yield* Effect.log("[cli] Prepared package.json for publish"); const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir); return { iconBackups }; diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts new file mode 100644 index 00000000000..04c2321870e --- /dev/null +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -0,0 +1,439 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import process from "node:process"; +import readline from "node:readline"; +import * as NodeTimers from "node:timers"; + +type JsonPrimitive = null | boolean | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcId = number | string; + +type JsonRpcMessage = { + jsonrpc?: string; + id?: JsonRpcId; + method?: string; + params?: JsonValue; + result?: JsonValue; + error?: JsonValue; + headers?: JsonValue; +}; + +type SelectLeafOption = { + value: string; + label?: string; + name?: string; +}; + +type SelectGroupOption = { + label?: string; + name?: string; + options: SelectLeafOption[]; +}; + +type SessionConfigOption = { + id: string; + name?: string; + category?: string; + type?: string; + options?: Array; +}; + +type SessionNewResult = { + sessionId: string; + configOptions?: SessionConfigOption[]; +}; + +type SetConfigResult = { + configOptions?: SessionConfigOption[]; +}; + +type PendingRequest = { + method: string; + resolve: (value: JsonValue | undefined) => void; + reject: (error: Error) => void; +}; + +const targetCwd = process.argv[2] ?? process.cwd(); +const targetModel = process.argv[3] ?? "gpt-5.4"; +const promptText = process.argv[4] ?? "helo"; +const targetReasoning = process.env.CURSOR_REASONING ?? ""; +const targetContext = process.env.CURSOR_CONTEXT ?? ""; +const targetFast = process.env.CURSOR_FAST ?? ""; +const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent"; +const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); +const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); + +function logSection(title: string, value: unknown) { + process.stdout.write(`\n=== ${title} ===\n`); + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function fail(message: string): never { + throw new Error(message); +} + +function asString(value: JsonValue | undefined): string | null { + return typeof value === "string" ? value : null; +} + +function flattenSelectValues(option: SessionConfigOption | undefined): string[] { + if (!option || option.type !== "select" || !Array.isArray(option.options)) { + return []; + } + + const values: string[] = []; + for (const entry of option.options) { + if (!entry || typeof entry !== "object") { + continue; + } + if ("value" in entry && typeof entry.value === "string") { + values.push(entry.value); + continue; + } + if ("options" in entry && Array.isArray(entry.options)) { + for (const nested of entry.options) { + if (nested && typeof nested === "object" && typeof nested.value === "string") { + values.push(nested.value); + } + } + } + } + return values; +} + +function findConfigOption( + configOptions: SessionConfigOption[], + predicate: (option: SessionConfigOption) => boolean, +): SessionConfigOption | undefined { + return configOptions.find(predicate); +} + +function matchesKeyword(option: SessionConfigOption, keyword: string): boolean { + const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase(); + return haystack.includes(keyword.toLowerCase()); +} + +function sleep(ms: number) { + return new Promise((resolve) => { + // @effect-diagnostics-next-line globalTimers:off - Standalone Node probe script, not an Effect runtime test. + NodeTimers.setTimeout(resolve, ms); + }); +} + +class JsonRpcChild { + readonly child: ChildProcessWithoutNullStreams; + readonly pending = new Map(); + nextId = 1; + closed = false; + + constructor(bin: string, args: string[], cwd: string) { + this.child = spawn(bin, args, { + cwd, + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + + this.child.on("exit", (code, signal) => { + this.closed = true; + const detail = `ACP process exited (code=${String(code)}, signal=${String(signal)})`; + for (const pending of this.pending.values()) { + pending.reject(new Error(`${detail} while waiting for ${pending.method}`)); + } + this.pending.clear(); + }); + + this.child.on("error", (error) => { + this.closed = true; + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + }); + + const stdout = readline.createInterface({ input: this.child.stdout }); + stdout.on("line", (line) => { + void this.handleStdoutLine(line); + }); + + const stderr = readline.createInterface({ input: this.child.stderr }); + stderr.on("line", (line) => { + process.stdout.write(`[stderr] ${line}\n`); + }); + } + + write(message: JsonRpcMessage) { + if (this.closed) { + fail("ACP process is already closed."); + } + const payload = JSON.stringify({ + jsonrpc: "2.0", + headers: [], + ...message, + }); + process.stdout.write(`>>> ${payload}\n`); + this.child.stdin.write(`${payload}\n`); + } + + async request(method: string, params: JsonValue, timeoutMs = requestTimeoutMs) { + const id = this.nextId++; + + const responsePromise = new Promise((resolve, reject) => { + // @effect-diagnostics-next-line globalTimers:off - Standalone Node probe script request timeout. + const timeout = NodeTimers.setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method} response after ${timeoutMs}ms.`)); + }, timeoutMs); + + this.pending.set(id, { + method, + resolve: (value) => { + NodeTimers.clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + NodeTimers.clearTimeout(timeout); + reject(error); + }, + }); + }); + + this.write({ + id, + method, + params, + }); + + return responsePromise; + } + + notify(method: string, params: JsonValue) { + this.write({ + method, + params, + }); + } + + respond(id: JsonRpcId, result: JsonValue) { + this.write({ + id, + result, + }); + } + + respondError(id: JsonRpcId, code: number, message: string) { + this.write({ + id, + error: { + code, + message, + }, + }); + } + + async handleStdoutLine(line: string) { + if (line.trim().length === 0) { + return; + } + + process.stdout.write(`<<< ${line}\n`); + + let message: JsonRpcMessage; + try { + message = JSON.parse(line) as JsonRpcMessage; + } catch (error) { + process.stdout.write(`[parse-error] ${(error as Error).message}\n`); + return; + } + + if (typeof message.id !== "undefined" && !message.method) { + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + this.pending.delete(message.id); + if (typeof message.error !== "undefined") { + pending.reject( + new Error(`RPC ${pending.method} failed: ${JSON.stringify(message.error, null, 2)}`), + ); + return; + } + pending.resolve(message.result); + return; + } + + if (message.method === "session/request_permission" && typeof message.id !== "undefined") { + this.respond(message.id, { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }); + return; + } + + if (typeof message.id !== "undefined" && message.id !== "") { + this.respondError( + message.id, + -32601, + `Unhandled server request: ${message.method ?? "unknown"}`, + ); + } + } + + async close() { + if (this.closed) { + return; + } + this.child.kill("SIGTERM"); + await sleep(250); + if (!this.closed) { + this.child.kill("SIGKILL"); + } + } +} + +async function setSelectOptionIfAdvertised( + rpc: JsonRpcChild, + sessionId: string, + configOptions: SessionConfigOption[], + predicate: (option: SessionConfigOption) => boolean, + value: string, + label: string, +) { + if (value.length === 0) { + return configOptions; + } + + const option = findConfigOption(configOptions, predicate); + const values = flattenSelectValues(option); + if (!option || !values.includes(value)) { + logSection(`SKIP_${label}`, { + requestedValue: value, + availableValues: values, + }); + return configOptions; + } + + const response = (await rpc.request("session/set_config_option", { + sessionId, + configId: option.id, + value, + })) as SetConfigResult | null | undefined; + + logSection(`SET_${label}_RESPONSE`, response); + return response?.configOptions ?? configOptions; +} + +async function main() { + const rpc = new JsonRpcChild(agentBin, ["acp"], targetCwd); + + try { + const initializeResponse = await rpc.request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { + name: "cursor-acp-model-mismatch-probe", + version: "0.0.0", + }, + }); + logSection("INITIALIZE_RESPONSE", initializeResponse); + + const authenticateResponse = await rpc.request("authenticate", { + methodId: "cursor_login", + }); + logSection("AUTHENTICATE_RESPONSE", authenticateResponse); + + const sessionResponse = (await rpc.request("session/new", { + cwd: targetCwd, + mcpServers: [], + })) as SessionNewResult; + logSection("SESSION_NEW_RESPONSE", sessionResponse); + + const sessionId = asString(sessionResponse.sessionId); + if (!sessionId) { + fail("session/new did not return a sessionId."); + } + + let configOptions = sessionResponse.configOptions ?? []; + const modelConfig = findConfigOption(configOptions, (option) => option.category === "model"); + const advertisedModels = flattenSelectValues(modelConfig); + logSection("ADVERTISED_MODEL_VALUES", advertisedModels); + + if (!modelConfig || modelConfig.type !== "select") { + fail("Cursor ACP did not expose a select-type model config option."); + } + + if (!advertisedModels.includes(targetModel)) { + fail( + `Cursor ACP did not advertise model ${JSON.stringify(targetModel)}. Advertised values: ${advertisedModels.join(", ")}`, + ); + } + + const setModelResponse = (await rpc.request("session/set_config_option", { + sessionId, + configId: modelConfig.id, + value: targetModel, + })) as SetConfigResult | null | undefined; + logSection("SET_MODEL_RESPONSE", setModelResponse); + + configOptions = setModelResponse?.configOptions ?? configOptions; + + configOptions = await setSelectOptionIfAdvertised( + rpc, + sessionId, + configOptions, + (option) => option.category === "thought_level", + targetReasoning, + "REASONING", + ); + + configOptions = await setSelectOptionIfAdvertised( + rpc, + sessionId, + configOptions, + (option) => option.category === "model_config" && matchesKeyword(option, "context"), + targetContext, + "CONTEXT", + ); + + configOptions = await setSelectOptionIfAdvertised( + rpc, + sessionId, + configOptions, + (option) => option.category === "model_config" && matchesKeyword(option, "fast"), + targetFast, + "FAST", + ); + + const promptResponse = await rpc.request("session/prompt", { + sessionId, + prompt: [ + { + type: "text", + text: promptText, + }, + ], + }); + logSection("PROMPT_RESPONSE", promptResponse); + + await sleep(promptWaitMs); + rpc.notify("session/cancel", { sessionId }); + } finally { + await rpc.close(); + } +} + +void main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts new file mode 100644 index 00000000000..764b6781919 --- /dev/null +++ b/apps/server/src/atomicWrite.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; + +export const writeFileStringAtomically = (input: { + readonly filePath: string; + readonly contents: string; +}) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempFileId = yield* Random.nextUUIDv4; + const targetDirectory = path.dirname(input.filePath); + + yield* fs.makeDirectory(targetDirectory, { recursive: true }); + const tempDirectory = yield* fs.makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.filePath)}.`, + }); + const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`); + + yield* fs.writeFileString(tempPath, input.contents); + yield* fs.rename(tempPath, input.filePath); + }), + ); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index cd7e1faec85..8c6999a7341 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -1,9 +1,10 @@ -import path from "node:path"; +// @effect-diagnostics nodeBuiltinImport:off +import NodePath from "node:path"; export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { - const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); + const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, ""); if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { return null; } @@ -19,9 +20,9 @@ export function resolveAttachmentRelativePath(input: { return null; } - const attachmentsRoot = path.resolve(input.attachmentsDir); - const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); - if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { + const attachmentsRoot = NodePath.resolve(input.attachmentsDir); + const filePath = NodePath.resolve(NodePath.join(attachmentsRoot, normalizedRelativePath)); + if (!filePath.startsWith(`${attachmentsRoot}${NodePath.sep}`)) { return null; } return filePath; diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e92c3d219d3..842667e61ba 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import os from "node:os"; import path from "node:path"; diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index aa85b8c51a3..1e8dd93f603 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.test.ts b/apps/server/src/auth/Layers/AuthControlPlane.test.ts index 9fc091124be..23ea4e97958 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.test.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.test.ts @@ -1,8 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -import { ServerConfigShape } from "../../config.ts"; +import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts index 98b2107800c..aea07fd286f 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -1,5 +1,7 @@ import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; -import { DateTime, Effect, Layer } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; @@ -10,8 +12,10 @@ import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Lay import { AuthControlPlane, AuthControlPlaneError, - AuthControlPlaneShape, DEFAULT_SESSION_SUBJECT, +} from "../Services/AuthControlPlane.ts"; +import type { + AuthControlPlaneShape, IssuedBearerSession, IssuedPairingLink, } from "../Services/AuthControlPlane.ts"; diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts index ec110ee96fb..110dae84379 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Duration, Effect, Layer } from "effect"; -import { TestClock } from "effect/testing"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index 5539f62c708..77d865eca68 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -1,6 +1,12 @@ import type { AuthPairingLink } from "@t3tools/contracts"; -import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect"; -import { Option } from "effect"; +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 PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as Option from "effect/Option"; import { ServerConfig } from "../../config.ts"; import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; diff --git a/apps/server/src/auth/Layers/ServerAuth.test.ts b/apps/server/src/auth/Layers/ServerAuth.test.ts index 0c3d71cc9fd..748f4ded731 100644 --- a/apps/server/src/auth/Layers/ServerAuth.test.ts +++ b/apps/server/src/auth/Layers/ServerAuth.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index cb1c6fa4c41..238475aca37 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -6,7 +6,10 @@ import { type AuthSessionState, type AuthWebSocketTokenResult, } from "@t3tools/contracts"; -import { DateTime, Effect, Layer, Option } from "effect"; +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 HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts index 13ca0233ee1..fcc56686d8f 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index 43735b47618..2c000bc1883 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -1,5 +1,6 @@ import type { ServerAuthDescriptor } from "@t3tools/contracts"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { ServerConfig } from "../../config.ts"; import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts index 7e6352eec25..30b5a9d35ed 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.test.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -1,6 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index c8acf11babe..d80242280bd 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -1,6 +1,10 @@ import * as Crypto from "node:crypto"; -import { Effect, FileSystem, Layer, Path, Predicate } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts index bafd9c85c2c..5609c2fdea2 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.test.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Duration, Effect, Layer } from "effect"; -import { TestClock } from "effect/testing"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 5ff4bbffff2..31e2b13240a 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -1,6 +1,14 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts"; -import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; -import { Option } from "effect"; +import * as Clock from "effect/Clock"; +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 PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Option from "effect/Option"; import { ServerConfig } from "../../config.ts"; import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; @@ -192,6 +200,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { ), ); + const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionCredentialServiceShape["issue"] = (input) => Effect.gen(function* () { const sessionId = AuthSessionId.make(crypto.randomUUID()); @@ -209,7 +218,13 @@ export const makeSessionCredentialService = Effect.gen(function* () { iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; - const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + + const encodedPayload = yield* encodeClaims(claims).pipe( + Effect.map(base64UrlEncode), + Effect.mapError( + (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }), + ), + ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); yield* authSessions.create({ @@ -297,12 +312,19 @@ export const makeSessionCredentialService = Effect.gen(function* () { }); } + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new SessionCredentialError({ + message: "Invalid `exp` claim", + }); + } + return { sessionId: claims.sid, token, method: claims.method, client: toClientMetadata(row.value.client), - expiresAt: DateTime.makeUnsafe(claims.exp), + expiresAt: expiresAt.value, subject: claims.sub, role: claims.role, } satisfies VerifiedSession; @@ -317,6 +339,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { ), ); + const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = ( sessionId, input, @@ -333,7 +356,12 @@ export const makeSessionCredentialService = Effect.gen(function* () { iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; - const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const encodedPayload = yield* encodeWsClaims(claims).pipe( + Effect.map(base64UrlEncode), + Effect.mapError( + (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }), + ), + ); const signature = signPayload(encodedPayload, signingSecret); return { token: `${encodedPayload}.${signature}`, diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts index b59e330bcaa..b5e67639a65 100644 --- a/apps/server/src/auth/Services/AuthControlPlane.ts +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -4,8 +4,12 @@ import type { AuthPairingLink, AuthSessionId, } from "@t3tools/contracts"; -import { Data, DateTime, Duration, Effect, Context } from "effect"; -import { SessionRole } from "./SessionCredentialService"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import type { SessionRole } from "./SessionCredentialService.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts index bcb6119a22e..70dd1d5aead 100644 --- a/apps/server/src/auth/Services/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -1,6 +1,10 @@ import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts"; -import { Data, DateTime, Duration, Context } from "effect"; -import type { Effect, Stream } from "effect"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; export type BootstrapCredentialRole = "owner" | "client"; diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts index 07be98269bf..3ea93b77f94 100644 --- a/apps/server/src/auth/Services/ServerAuth.ts +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -12,8 +12,10 @@ import type { ServerAuthSessionMethod, AuthWebSocketTokenResult, } from "@t3tools/contracts"; -import { Data, DateTime, Context } from "effect"; -import type { Effect } from "effect"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import type { SessionRole } from "./SessionCredentialService.ts"; diff --git a/apps/server/src/auth/Services/ServerAuthPolicy.ts b/apps/server/src/auth/Services/ServerAuthPolicy.ts index 530d776998c..5d9ef68cf95 100644 --- a/apps/server/src/auth/Services/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Services/ServerAuthPolicy.ts @@ -1,6 +1,6 @@ import type { ServerAuthDescriptor } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; export interface ServerAuthPolicyShape { readonly getDescriptor: () => Effect.Effect; diff --git a/apps/server/src/auth/Services/ServerSecretStore.ts b/apps/server/src/auth/Services/ServerSecretStore.ts index 7d97b4c3a10..f5c6f6dfef4 100644 --- a/apps/server/src/auth/Services/ServerSecretStore.ts +++ b/apps/server/src/auth/Services/ServerSecretStore.ts @@ -1,5 +1,6 @@ -import { Data, Context } from "effect"; -import type { Effect } from "effect"; +import * as Data from "effect/Data"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ readonly message: string; diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts index 3d72b5a636c..7dc049c910e 100644 --- a/apps/server/src/auth/Services/SessionCredentialService.ts +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -4,8 +4,12 @@ import type { AuthSessionId, ServerAuthSessionMethod, } from "@t3tools/contracts"; -import { Data, DateTime, Duration, Context } from "effect"; -import type { Effect, Stream } from "effect"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; export type SessionRole = "owner" | "client"; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 76c14646e98..670ff5abbff 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -6,12 +6,15 @@ import { AuthRevokePairingLinkInput, type AuthWebSocketTokenResult, } from "@t3tools/contracts"; -import { DateTime, Effect, Schema } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; +import { browserApiCorsHeaders } from "../httpCors.ts"; export const respondToAuthError = (error: AuthError) => Effect.gen(function* () { @@ -25,7 +28,7 @@ export const respondToAuthError = (error: AuthError) => { error: error.message, }, - { status: error.status ?? 500 }, + { status: error.status ?? 500, headers: browserApiCorsHeaders }, ); }); @@ -36,7 +39,10 @@ export const authSessionRouteLayer = HttpRouter.add( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; const session = yield* serverAuth.getSessionState(request); - return HttpServerResponse.jsonUnsafe(session, { status: 200 }); + return HttpServerResponse.jsonUnsafe(session, { + status: 200, + headers: browserApiCorsHeaders, + }); }), ); @@ -79,7 +85,10 @@ export const authBootstrapRouteLayer = HttpRouter.add( deriveAuthClientMetadata({ request }), ); - return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe( + return yield* HttpServerResponse.jsonUnsafe(result.response, { + status: 200, + headers: browserApiCorsHeaders, + }).pipe( HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, { expires: DateTime.toDate(result.response.expiresAt), httpOnly: true, @@ -112,6 +121,7 @@ export const authBearerBootstrapRouteLayer = HttpRouter.add( ); return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, { status: 200, + headers: browserApiCorsHeaders, }); }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), ); @@ -126,6 +136,7 @@ export const authWebSocketTokenRouteLayer = HttpRouter.add( const result = yield* serverAuth.issueWebSocketToken(session); return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, { status: 200, + headers: browserApiCorsHeaders, }); }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), ); diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index a767b77de11..e7a540d81ba 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { deriveAuthClientMetadata } from "./utils"; +import { deriveAuthClientMetadata } from "./utils.ts"; describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { diff --git a/apps/server/src/cli.test.ts b/apps/server/src/bin.test.ts similarity index 94% rename from apps/server/src/cli.test.ts rename to apps/server/src/bin.test.ts index 7ebde01067a..a7bf2686101 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/bin.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - NodeHttpServer.layer takes `NodeHttp.createServer` as arg import * as NodeHttp from "node:http"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -5,7 +6,7 @@ import { join } from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { NetService } from "@t3tools/shared/Net"; +import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -15,9 +16,8 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; -import { cli } from "./cli.ts"; +import { cli } from "./bin.ts"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { @@ -76,6 +76,8 @@ const makeCliTestServerConfig = (baseDir: string) => desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, } satisfies ServerConfigShape; }); @@ -137,7 +139,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef } yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, - state: makePersistedServerRuntimeState({ + state: yield* makePersistedServerRuntimeState({ config, port: address.port, }), @@ -147,7 +149,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ); }); -it.layer(NodeServices.layer)("cli log-level parsing", (it) => { +it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => runCliWithRuntime(["--log-level", "debug", "--version"]), ); @@ -178,6 +180,7 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { const createdOutput = yield* captureStdout( runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), ); + // @effect-diagnostics-next-line preferSchemaOverJson:off const created = JSON.parse(createdOutput.output) as { readonly id: string; readonly credential: string; @@ -185,6 +188,7 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { const listedOutput = yield* captureStdout( runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), ); + // @effect-diagnostics-next-line preferSchemaOverJson:off const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ readonly id: string; readonly credential?: string; @@ -206,6 +210,7 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { const issuedOutput = yield* captureStdout( runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), ); + // @effect-diagnostics-next-line preferSchemaOverJson:off const issued = JSON.parse(issuedOutput.output) as { readonly sessionId: string; readonly token: string; @@ -214,6 +219,7 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { const listedOutput = yield* captureStdout( runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), ); + // @effect-diagnostics-next-line preferSchemaOverJson:off const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ readonly sessionId: string; readonly token?: string; @@ -314,8 +320,8 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { "--base-dir", baseDir, ]); - const orchestrationEngine = yield* OrchestrationEngineService; - const readModel = yield* orchestrationEngine.getReadModel(); + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const readModel = yield* projectionSnapshotQuery.getSnapshot(); const addedProject = readModel.projects.find( (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063d43326c3..4e829332c17 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -4,14 +4,25 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { Command } from "effect/unstable/cli"; -import { NetService } from "@t3tools/shared/Net"; -import { cli } from "./cli"; -import { version } from "../package.json" with { type: "json" }; +import * as NetService from "@t3tools/shared/Net"; +import packageJson from "../package.json" with { type: "json" }; +import { authCommand } from "./cli/auth.ts"; +import { sharedServerCommandFlags } from "./cli/config.ts"; +import { projectCommand } from "./cli/project.ts"; +import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -Command.run(cli, { version }).pipe( - Effect.scoped, - Effect.provide(CliRuntimeLayer), - NodeRuntime.runMain, +export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), + Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), ); + +if (import.meta.main) { + Command.run(cli, { version: packageJson.version }).pipe( + Effect.scoped, + Effect.provide(CliRuntimeLayer), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 3fce6af9c42..19c84aa9d01 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -1,16 +1,18 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NFS from "node:fs"; import * as path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { FileSystem, Schema } from "effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import { TestClock } from "effect/testing"; +import * as TestClock from "effect/testing/TestClock"; import { vi } from "vitest"; -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); @@ -36,6 +38,7 @@ vi.mock("node:fs", async (importOriginal) => { }); const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); +const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { it.effect("uses platform-specific fd paths", () => @@ -53,9 +56,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { yield* fs.writeFileString( filePath, - `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ - mode: "desktop", - })}\n`, + `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`, ); const fd = yield* Effect.acquireRelease( @@ -77,9 +78,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { yield* fs.writeFileString( filePath, - `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ - mode: "desktop", - })}\n`, + `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`, ); // Open without acquireRelease: the direct-stream fallback uses autoClose: true, diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 0fb13522686..9ad6328798d 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -1,9 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as NFS from "node:fs"; import * as Net from "node:net"; import * as readline from "node:readline"; import type { Readable } from "node:stream"; -import { Data, Effect, Option, Predicate, Result, Schema } from "effect"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; class BootstrapError extends Data.TaggedError("BootstrapError")<{ diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index cb873559c16..1d8e113a4eb 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,6 +1,6 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; -import { GitCommandError } from "@t3tools/contracts"; +import type { VcsError } from "@t3tools/contracts"; /** * CheckpointUnavailableError - Expected checkpoint does not exist. @@ -35,9 +35,6 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass { + it("uses the narrow full-thread context lookup for all-turns diffs", async () => { + const projectId = ProjectId.make("project-full-thread"); + const threadId = ThreadId.make("thread-full-thread"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); + let getThreadCheckpointContextCalls = 0; + let getFullThreadDiffContextCalls = 0; + const diffCheckpointsCalls: Array<{ + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; + readonly cwd: string; + readonly ignoreWhitespace: boolean; + }> = []; + + const checkpointStore: CheckpointStoreShape = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => + Effect.sync(() => { + diffCheckpointsCalls.push({ + fromCheckpointRef, + toCheckpointRef, + cwd, + ignoreWhitespace, + }); + return "full thread diff patch"; + }), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQueryLive.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => + Effect.sync(() => { + getThreadCheckpointContextCalls += 1; + return Option.none(); + }), + getFullThreadDiffContext: () => + Effect.sync(() => { + getFullThreadDiffContextCalls += 1; + return Option.some({ + threadId, + projectId, + workspaceRoot: "/tmp/workspace", + worktreePath: "/tmp/worktree", + latestCheckpointTurnCount: 4, + toCheckpointRef, + }); + }), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const query = yield* CheckpointDiffQuery; + return yield* query.getFullThreadDiff({ + threadId, + toTurnCount: 4, + ignoreWhitespace: true, + }); + }).pipe(Effect.provide(layer)), + ); + + expect(getThreadCheckpointContextCalls).toBe(0); + expect(getFullThreadDiffContextCalls).toBe(1); + expect(diffCheckpointsCalls).toEqual([ + { + cwd: "/tmp/worktree", + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef, + ignoreWhitespace: true, + }, + ]); + expect(result).toEqual({ + threadId, + fromTurnCount: 0, + toTurnCount: 4, + diff: "full thread diff patch", + }); + }); + it("computes diffs using canonical turn-0 checkpoint refs", async () => { const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const hasCheckpointRefCalls: Array = []; const diffCheckpointsCalls: Array<{ readonly fromCheckpointRef: CheckpointRef; readonly toCheckpointRef: CheckpointRef; readonly cwd: string; + readonly ignoreWhitespace: boolean; }> = []; const threadCheckpointContext = makeThreadCheckpointContext({ @@ -62,15 +164,16 @@ describe("CheckpointDiffQueryLive", () => { const checkpointStore: CheckpointStoreShape = { isGitRepository: () => Effect.succeed(true), captureCheckpoint: () => Effect.void, - hasCheckpointRef: ({ checkpointRef }) => - Effect.sync(() => { - hasCheckpointRefCalls.push(checkpointRef); - return true; - }), + hasCheckpointRef: () => Effect.succeed(true), restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) => + diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => Effect.sync(() => { - diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd }); + diffCheckpointsCalls.push({ + fromCheckpointRef, + toCheckpointRef, + cwd, + ignoreWhitespace, + }); return "diff patch"; }), deleteCheckpointRefs: () => Effect.void, @@ -80,12 +183,23 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); @@ -97,17 +211,18 @@ describe("CheckpointDiffQueryLive", () => { threadId, fromTurnCount: 0, toTurnCount: 1, + ignoreWhitespace: true, }); }).pipe(Effect.provide(layer)), ); const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); - expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]); expect(diffCheckpointsCalls).toEqual([ { cwd: "/tmp/workspace", fromCheckpointRef: expectedFromRef, toCheckpointRef, + ignoreWhitespace: true, }, ]); expect(result).toEqual({ @@ -118,6 +233,141 @@ describe("CheckpointDiffQueryLive", () => { }); }); + it("defaults to hide whitespace changes", async () => { + const projectId = ProjectId.make("project-default-whitespace"); + const threadId = ThreadId.make("thread-default-whitespace"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; + + const threadCheckpointContext = makeThreadCheckpointContext({ + projectId, + threadId, + workspaceRoot: "/tmp/workspace", + worktreePath: null, + checkpointTurnCount: 1, + checkpointRef: toCheckpointRef, + }); + + const checkpointStore: CheckpointStoreShape = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: ({ ignoreWhitespace }) => + Effect.sync(() => { + diffCheckpointsCalls.push({ ignoreWhitespace }); + return "diff patch"; + }), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQueryLive.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const query = yield* CheckpointDiffQuery; + return yield* query.getTurnDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + }).pipe(Effect.provide(layer)), + ); + + expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); + }); + + it("does not preflight checkpoint refs before diffing", async () => { + const projectId = ProjectId.make("project-no-preflight"); + const threadId = ThreadId.make("thread-no-preflight"); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + let hasCheckpointRefCallCount = 0; + + const threadCheckpointContext = makeThreadCheckpointContext({ + projectId, + threadId, + workspaceRoot: "/tmp/workspace", + worktreePath: null, + checkpointTurnCount: 1, + checkpointRef: toCheckpointRef, + }); + + const checkpointStore: CheckpointStoreShape = { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => + Effect.sync(() => { + hasCheckpointRefCallCount += 1; + return true; + }), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: () => Effect.succeed("diff patch"), + deleteCheckpointRefs: () => Effect.void, + }; + + const layer = CheckpointDiffQueryLive.pipe( + Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), + getSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + }), + ), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const query = yield* CheckpointDiffQuery; + return yield* query.getTurnDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 1, + ignoreWhitespace: true, + }); + }).pipe(Effect.provide(layer)), + ); + + expect(hasCheckpointRefCallCount).toBe(0); + }); + it("fails when the thread is missing from the snapshot", async () => { const threadId = ThreadId.make("thread-missing"); @@ -134,12 +384,23 @@ describe("CheckpointDiffQueryLive", () => { Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.die("CheckpointDiffQuery should not request the command read model"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index 1c2edee469e..b07c06ac936 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -1,10 +1,14 @@ import { + type CheckpointRef, OrchestrationGetTurnDiffResult, - type OrchestrationGetFullThreadDiffInput, + type ThreadId, type OrchestrationGetFullThreadDiffResult, type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, } from "@t3tools/contracts"; -import { Effect, Layer, Option, Schema } from "effect"; +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 { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; @@ -17,6 +21,22 @@ import { const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); +function buildTurnDiffResult( + input: { + readonly threadId: ThreadId; + readonly fromTurnCount: number; + readonly toTurnCount: number; + }, + diff: string, +): OrchestrationGetTurnDiffResultType { + return { + threadId: input.threadId, + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + diff, + }; +} + const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const checkpointStore = yield* CheckpointStore; @@ -24,6 +44,13 @@ const make = Effect.gen(function* () { const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")( function* (input) { const operation = "CheckpointDiffQuery.getTurnDiff"; + const ignoreWhitespace = input.ignoreWhitespace ?? true; + yield* Effect.annotateCurrentSpan({ + "checkpoint.thread_id": input.threadId, + "checkpoint.from_turn_count": input.fromTurnCount, + "checkpoint.to_turn_count": input.toTurnCount, + "checkpoint.ignore_whitespace": ignoreWhitespace, + }); if (input.fromTurnCount === input.toTurnCount) { const emptyDiff: OrchestrationGetTurnDiffResultType = { @@ -41,9 +68,9 @@ const make = Effect.gen(function* () { return emptyDiff; } - const threadContext = yield* projectionSnapshotQuery.getThreadCheckpointContext( - input.threadId, - ); + const threadContext = yield* projectionSnapshotQuery + .getThreadCheckpointContext(input.threadId) + .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); if (Option.isNone(threadContext)) { return yield* new CheckpointInvariantError({ operation, @@ -96,68 +123,121 @@ const make = Effect.gen(function* () { }); } - const [fromExists, toExists] = yield* Effect.all( - [ - checkpointStore.hasCheckpointRef({ - cwd: workspaceCwd, - checkpointRef: fromCheckpointRef, - }), - checkpointStore.hasCheckpointRef({ - cwd: workspaceCwd, - checkpointRef: toCheckpointRef, - }), - ], - { concurrency: "unbounded" }, - ); - - if (!fromExists) { - return yield* new CheckpointUnavailableError({ - threadId: input.threadId, - turnCount: input.fromTurnCount, - detail: `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount}.`, + const diff = yield* checkpointStore + .diffCheckpoints({ + cwd: workspaceCwd, + fromCheckpointRef, + toCheckpointRef, + fallbackFromToHead: false, + ignoreWhitespace, + }) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); + + const turnDiff = buildTurnDiffResult(input, diff); + if (!isTurnDiffResult(turnDiff)) { + return yield* new CheckpointInvariantError({ + operation, + detail: "Computed turn diff result does not satisfy contract schema.", }); } - if (!toExists) { - return yield* new CheckpointUnavailableError({ + return turnDiff; + }, + ); + + const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = Effect.fn( + "CheckpointDiffQuery.getFullThreadDiff", + )(function* (input) { + const operation = "CheckpointDiffQuery.getFullThreadDiff"; + const ignoreWhitespace = input.ignoreWhitespace ?? true; + yield* Effect.annotateCurrentSpan({ + "checkpoint.thread_id": input.threadId, + "checkpoint.from_turn_count": 0, + "checkpoint.to_turn_count": input.toTurnCount, + "checkpoint.ignore_whitespace": ignoreWhitespace, + "checkpoint.diff_kind": "full-thread", + }); + + if (input.toTurnCount === 0) { + const emptyDiff = buildTurnDiffResult( + { threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Filesystem checkpoint is unavailable for turn ${input.toTurnCount}.`, + fromTurnCount: 0, + toTurnCount: 0, + }, + "", + ); + if (!isTurnDiffResult(emptyDiff)) { + return yield* new CheckpointInvariantError({ + operation, + detail: "Computed full thread diff result does not satisfy contract schema.", }); } + return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; + } + + const threadContext = yield* projectionSnapshotQuery + .getFullThreadDiffContext(input.threadId, input.toTurnCount) + .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); - const diff = yield* checkpointStore.diffCheckpoints({ + if (Option.isNone(threadContext)) { + return yield* new CheckpointInvariantError({ + operation, + detail: `Thread '${input.threadId}' not found.`, + }); + } + + if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { + return yield* new CheckpointUnavailableError({ + threadId: input.threadId, + turnCount: input.toTurnCount, + detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, + }); + } + + const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; + if (!workspaceCwd) { + return yield* new CheckpointInvariantError({ + operation, + detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, + }); + } + + if (!threadContext.value.toCheckpointRef) { + return yield* new CheckpointUnavailableError({ + threadId: input.threadId, + turnCount: input.toTurnCount, + detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, + }); + } + + const diff = yield* checkpointStore + .diffCheckpoints({ cwd: workspaceCwd, - fromCheckpointRef, - toCheckpointRef, + fromCheckpointRef: checkpointRefForThreadTurn(input.threadId, 0), + toCheckpointRef: threadContext.value.toCheckpointRef as CheckpointRef, fallbackFromToHead: false, - }); + ignoreWhitespace, + }) + .pipe(Effect.withSpan("checkpoint.fullThread.diffCheckpoints")); - const turnDiff: OrchestrationGetTurnDiffResultType = { + const turnDiff = buildTurnDiffResult( + { threadId: input.threadId, - fromTurnCount: input.fromTurnCount, + fromTurnCount: 0, toTurnCount: input.toTurnCount, - diff, - }; - if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ - operation, - detail: "Computed turn diff result does not satisfy contract schema.", - }); - } - - return turnDiff; - }, - ); + }, + diff, + ); + if (!isTurnDiffResult(turnDiff)) { + return yield* new CheckpointInvariantError({ + operation, + detail: "Computed full thread diff result does not satisfy contract schema.", + }); + } - const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = ( - input: OrchestrationGetFullThreadDiffInput, - ) => - getTurnDiff({ - threadId: input.threadId, - fromTurnCount: 0, - toTurnCount: input.toTurnCount, - }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); + return turnDiff satisfies OrchestrationGetFullThreadDiffResult; + }); return { getTurnDiff, diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index fe377eb1ec3..2d4fd8a7df2 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -1,31 +1,39 @@ +// @effect-diagnostics nodeBuiltinImport:off import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; import { describe, expect } from "vitest"; import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointStoreLive } from "./CheckpointStore.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; -import { GitCommandError } from "@t3tools/contracts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import type { VcsError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ThreadId } from "@t3tools/contracts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); -const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), -); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( - Layer.provide(GitCoreTestLayer), - Layer.provide(NodeServices.layer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(NodeServices.layer), +); +const TestLayer = CheckpointStoreTestLayer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), ); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer, CheckpointStoreTestLayer); function makeTmpDir( prefix = "checkpoint-store-test-", @@ -49,11 +57,12 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ operation: "CheckpointStore.test.git", + command: "git", cwd, args, timeoutMs: 10_000, @@ -66,12 +75,11 @@ function initRepoWithCommit( cwd: string, ): Effect.Effect< void, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem > { return Effect.gen(function* () { - const core = yield* GitCore; - yield* core.initRepo({ cwd }); + yield* git(cwd, ["init"]); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); @@ -111,6 +119,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { cwd: tmp, fromCheckpointRef, toCheckpointRef, + ignoreWhitespace: true, }); expect(diff).toContain("diff --git"); @@ -118,5 +127,80 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { expect(diff).toContain("+line 04999"); }), ); + + it.effect("can hide indentation churn when changes wrap existing lines", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore; + const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + const componentPath = path.join(tmp, "Component.tsx"); + yield* writeTextFile( + componentPath, + [ + "export function View() {", + " return (", + "
", + "

Title

", + "

Body

", + "
", + " );", + "}", + "", + ].join("\n"), + ); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile( + componentPath, + [ + "export function View() {", + " return (", + "
", + " {isReady ? (", + "
", + "

Title

", + "

Body

", + "
", + " ) : null}", + "
", + " );", + "}", + "", + ].join("\n"), + ); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const normalDiff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: false, + }); + const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: true, + }); + + expect(normalDiff).toContain("diff --git"); + expect(normalDiff).toContain("-

Title

"); + expect(normalDiff).toContain("+

Title

"); + expect(whitespaceIgnoredDiff).toContain("diff --git"); + expect(whitespaceIgnoredDiff).toContain("+ {isReady ? ("); + expect(whitespaceIgnoredDiff).toContain("+
"); + expect(whitespaceIgnoredDiff).not.toContain("-

Title

"); + expect(whitespaceIgnoredDiff).not.toContain("+

Title

"); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 184ec96323e..9bd72e78b6b 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -1,272 +1,79 @@ /** * CheckpointStoreLive - Filesystem checkpoint store adapter layer. * - * Implements hidden Git-ref checkpoint capture/restore directly with - * Effect-native child process execution (`effect/unstable/process`). - * - * This layer owns filesystem/Git interactions only; it does not persist - * checkpoint metadata and does not coordinate provider rollback semantics. + * Resolves the active VCS driver once per checkpoint operation and delegates + * checkpoint-specific behavior to the driver's optional checkpoint capability. * * @module CheckpointStoreLive */ -import { randomUUID } from "node:crypto"; - -import { Effect, Layer, FileSystem, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -import { CheckpointInvariantError } from "../Errors.ts"; -import { GitCommandError } from "@t3tools/contracts"; -import { GitCore } from "../../git/Services/GitCore.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; -import { CheckpointRef } from "@t3tools/contracts"; +import { VcsUnsupportedOperationError } from "@t3tools/contracts"; +import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; +import type { VcsCheckpointOps } from "../../vcs/VcsDriver.ts"; const makeCheckpointStore = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const git = yield* GitCore; - - const resolveHeadCommit = (cwd: string): Effect.Effect => - git - .execute({ - operation: "CheckpointStore.resolveHeadCommit", - cwd, - args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], - allowNonZeroExit: true, - }) - .pipe( - Effect.map((result) => { - if (result.code !== 0) { - return null; - } - const commit = result.stdout.trim(); - return commit.length > 0 ? commit : null; - }), - ); + const vcsRegistry = yield* VcsDriverRegistry; - const hasHeadCommit = (cwd: string): Effect.Effect => - git - .execute({ - operation: "CheckpointStore.hasHeadCommit", - cwd, - args: ["rev-parse", "--verify", "HEAD"], - allowNonZeroExit: true, - }) - .pipe(Effect.map((result) => result.code === 0)); - - const resolveCheckpointCommit = ( + const resolveCheckpoints = Effect.fn("CheckpointStore.resolveCheckpoints")(function* ( + operation: string, cwd: string, - checkpointRef: CheckpointRef, - ): Effect.Effect => - git - .execute({ - operation: "CheckpointStore.resolveCheckpointCommit", - cwd, - args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], - allowNonZeroExit: true, - }) - .pipe( - Effect.map((result) => { - if (result.code !== 0) { - return null; - } - const commit = result.stdout.trim(); - return commit.length > 0 ? commit : null; - }), - ); + ) { + const handle = yield* vcsRegistry.resolve({ cwd }); + if (!handle.driver.checkpoints) { + return yield* new VcsUnsupportedOperationError({ + operation, + kind: handle.kind, + detail: `${handle.kind} driver does not implement checkpoint operations.`, + }); + } + return handle.driver.checkpoints satisfies VcsCheckpointOps; + }); const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - git - .execute({ - operation: "CheckpointStore.isGitRepository", - cwd, - args: ["rev-parse", "--is-inside-work-tree"], - allowNonZeroExit: true, - }) - .pipe( - Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), - Effect.catch(() => Effect.succeed(false)), - ); + vcsRegistry.resolve({ cwd, requestedKind: "git" }).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( "captureCheckpoint", )(function* (input) { - const operation = "CheckpointStore.captureCheckpoint"; - - yield* Effect.acquireUseRelease( - fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }), - Effect.fn("captureCheckpoint.withTempDirectory")(function* (tempDir) { - const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); - const commitEnv: NodeJS.ProcessEnv = { - ...process.env, - GIT_INDEX_FILE: tempIndexPath, - GIT_AUTHOR_NAME: "T3 Code", - GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", - GIT_COMMITTER_NAME: "T3 Code", - GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", - }; - - const headExists = yield* hasHeadCommit(input.cwd); - if (headExists) { - yield* git.execute({ - operation, - cwd: input.cwd, - args: ["read-tree", "HEAD"], - env: commitEnv, - }); - } - - yield* git.execute({ - operation, - cwd: input.cwd, - args: ["add", "-A", "--", "."], - env: commitEnv, - }); - - const writeTreeResult = yield* git.execute({ - operation, - cwd: input.cwd, - args: ["write-tree"], - env: commitEnv, - }); - const treeOid = writeTreeResult.stdout.trim(); - if (treeOid.length === 0) { - return yield* new GitCommandError({ - operation, - command: "git write-tree", - cwd: input.cwd, - detail: "git write-tree returned an empty tree oid.", - }); - } - - const message = `t3 checkpoint ref=${input.checkpointRef}`; - const commitTreeResult = yield* git.execute({ - operation, - cwd: input.cwd, - args: ["commit-tree", treeOid, "-m", message], - env: commitEnv, - }); - const commitOid = commitTreeResult.stdout.trim(); - if (commitOid.length === 0) { - return yield* new GitCommandError({ - operation, - command: "git commit-tree", - cwd: input.cwd, - detail: "git commit-tree returned an empty commit oid.", - }); - } - - yield* git.execute({ - operation, - cwd: input.cwd, - args: ["update-ref", input.checkpointRef, commitOid], - }); - }), - (tempDir) => fs.remove(tempDir, { recursive: true }), - ).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail( - new CheckpointInvariantError({ - operation: "CheckpointStore.captureCheckpoint", - detail: "Failed to capture checkpoint.", - cause: error, - }), - ), - }), - ); + const checkpoints = yield* resolveCheckpoints("CheckpointStore.captureCheckpoint", input.cwd); + return yield* checkpoints.captureCheckpoint(input); }); - const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => - resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( - Effect.map((commit) => commit !== null), - ); + const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = Effect.fn("hasCheckpointRef")( + function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.hasCheckpointRef", input.cwd); + return yield* checkpoints.hasCheckpointRef(input); + }, + ); const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn( "restoreCheckpoint", )(function* (input) { - const operation = "CheckpointStore.restoreCheckpoint"; - - let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); - - if (!commitOid && input.fallbackToHead === true) { - commitOid = yield* resolveHeadCommit(input.cwd); - } - - if (!commitOid) { - return false; - } - - yield* git.execute({ - operation, - cwd: input.cwd, - args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], - }); - yield* git.execute({ - operation, - cwd: input.cwd, - args: ["clean", "-fd", "--", "."], - }); - - const headExists = yield* hasHeadCommit(input.cwd); - if (headExists) { - yield* git.execute({ - operation, - cwd: input.cwd, - args: ["reset", "--quiet", "--", "."], - }); - } - - return true; + const checkpoints = yield* resolveCheckpoints("CheckpointStore.restoreCheckpoint", input.cwd); + return yield* checkpoints.restoreCheckpoint(input); }); const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( function* (input) { - const operation = "CheckpointStore.diffCheckpoints"; - - let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); - const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef); - - if (!fromCommitOid && input.fallbackFromToHead === true) { - const headCommit = yield* resolveHeadCommit(input.cwd); - if (headCommit) { - fromCommitOid = headCommit; - } - } - - if (!fromCommitOid || !toCommitOid) { - return yield* new GitCommandError({ - operation, - command: "git diff", - cwd: input.cwd, - detail: "Checkpoint ref is unavailable for diff operation.", - }); - } - - const result = yield* git.execute({ - operation, - cwd: input.cwd, - args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], - }); - - return result.stdout; + const checkpoints = yield* resolveCheckpoints("CheckpointStore.diffCheckpoints", input.cwd); + return yield* checkpoints.diffCheckpoints(input); }, ); const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { - const operation = "CheckpointStore.deleteCheckpointRefs"; - - yield* Effect.forEach( - input.checkpointRefs, - (checkpointRef) => - git.execute({ - operation, - cwd: input.cwd, - args: ["update-ref", "-d", checkpointRef], - allowNonZeroExit: true, - }), - { discard: true }, + const checkpoints = yield* resolveCheckpoints( + "CheckpointStore.deleteCheckpointRefs", + input.cwd, ); + return yield* checkpoints.deleteCheckpointRefs(input); }); return { diff --git a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts index d865256ac59..4bb8b111827 100644 --- a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts @@ -12,8 +12,8 @@ import type { OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { CheckpointServiceError } from "../Errors.ts"; diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index d9a43fa4e95..a7c4c3dbef0 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -10,8 +10,8 @@ * * @module CheckpointStore */ -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { CheckpointStoreError } from "../Errors.ts"; import { CheckpointRef } from "@t3tools/contracts"; @@ -32,6 +32,7 @@ export interface DiffCheckpointsInput { readonly fromCheckpointRef: CheckpointRef; readonly toCheckpointRef: CheckpointRef; readonly fallbackFromToHead?: boolean; + readonly ignoreWhitespace: boolean; } export interface DeleteCheckpointRefsInput { diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index c709aa3735c..50d0163af5a 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -1,4 +1,4 @@ -import { Encoding } from "effect"; +import * as Encoding from "effect/Encoding"; import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts deleted file mode 100644 index 5f737509202..00000000000 --- a/apps/server/src/cli.ts +++ /dev/null @@ -1,1132 +0,0 @@ -import { NetService } from "@t3tools/shared/Net"; -import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { - AuthSessionId, - CommandId, - OrchestrationReadModel, - ProjectId, - type ClientOrchestrationCommand, -} from "@t3tools/contracts"; -import { - Config, - Console, - Duration, - Effect, - Exit, - FileSystem, - Layer, - LogLevel, - Option, - Path, - References, - Schema, - SchemaIssue, - SchemaTransformation, -} from "effect"; -import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; - -import { - DEFAULT_PORT, - deriveServerPaths, - ensureServerDirectories, - resolveStaticDir, - ServerConfig, - RuntimeMode, - type ServerConfigShape, - type StartupPresentation, -} from "./config"; -import { readBootstrapEnvelope } from "./bootstrap"; -import { expandHomePath, resolveBaseDir } from "./os-jank"; -import { runServer } from "./server"; -import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; -import { - formatIssuedPairingCredential, - formatIssuedSession, - formatPairingCredentialList, - formatSessionList, -} from "./cliAuthFormat"; -import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; -import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; -import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup"; -import { - clearPersistedServerRuntimeState, - readPersistedServerRuntimeState, -} from "./serverRuntimeState"; -import { WorkspacePaths } from "./workspace/Services/WorkspacePaths"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; - -const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); - -const BootstrapEnvelopeSchema = Schema.Struct({ - mode: Schema.optional(RuntimeMode), - port: Schema.optional(PortSchema), - host: Schema.optional(Schema.String), - t3Home: Schema.optional(Schema.String), - devUrl: Schema.optional(Schema.URLFromString), - noBrowser: Schema.optional(Schema.Boolean), - desktopBootstrapToken: Schema.optional(Schema.String), - autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), - logWebSocketEvents: Schema.optional(Schema.Boolean), - otlpTracesUrl: Schema.optional(Schema.String), - otlpMetricsUrl: Schema.optional(Schema.String), -}); - -const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( - Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), - Flag.optional, -); -const portFlag = Flag.integer("port").pipe( - Flag.withSchema(PortSchema), - Flag.withDescription("Port for the HTTP/WebSocket server."), - Flag.optional, -); -const hostFlag = Flag.string("host").pipe( - Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), - Flag.optional, -); -const baseDirFlag = Flag.string("base-dir").pipe( - Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), - Flag.optional, -); -const devUrlFlag = Flag.string("dev-url").pipe( - Flag.withSchema(Schema.URLFromString), - Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), - Flag.optional, -); -const noBrowserFlag = Flag.boolean("no-browser").pipe( - Flag.withDescription("Disable automatic browser opening."), - Flag.optional, -); -const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( - Flag.withSchema(Schema.Int), - Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), - Flag.optional, -); -const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( - Flag.withDescription( - "Create a project for the current working directory on startup when missing.", - ), - Flag.optional, -); -const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( - Flag.withDescription( - "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", - ), - Flag.withAlias("log-ws-events"), - Flag.optional, -); - -const EnvServerConfig = Config.all({ - logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), - traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), - traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), - traceFile: Config.string("T3CODE_TRACE_FILE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - 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)), - otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( - Config.withDefault(10_000), - ), - otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), - mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), - host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), - t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), - devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), - noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), -}); - -interface CliServerFlags { - readonly mode: Option.Option; - readonly port: Option.Option; - readonly host: Option.Option; - readonly baseDir: Option.Option; - readonly cwd: Option.Option; - readonly devUrl: Option.Option; - readonly noBrowser: Option.Option; - readonly bootstrapFd: Option.Option; - readonly autoBootstrapProjectFromCwd: Option.Option; - readonly logWebSocketEvents: Option.Option; -} - -interface CliAuthLocationFlags { - readonly baseDir: Option.Option; - readonly devUrl?: Option.Option; -} - -const resolveOptionPrecedence = ( - ...values: ReadonlyArray> -): Option.Option => Option.firstSomeOf(values); - -const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { - const fs = yield* FileSystem.FileSystem; - const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } - - const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); - return parsePersistedServerObservabilitySettings(raw); -}); - -export const resolveServerConfig = ( - flags: CliServerFlags, - cliLogLevel: Option.Option, - options?: { - readonly startupPresentation?: StartupPresentation; - readonly forceAutoBootstrapProjectFromCwd?: boolean; - }, -) => - Effect.gen(function* () { - const { findAvailablePort } = yield* NetService; - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const env = yield* EnvServerConfig; - const normalizedFlags = { - mode: flags.mode ?? Option.none(), - port: flags.port ?? Option.none(), - host: flags.host ?? Option.none(), - baseDir: flags.baseDir ?? Option.none(), - cwd: flags.cwd ?? Option.none(), - devUrl: flags.devUrl ?? Option.none(), - noBrowser: flags.noBrowser ?? Option.none(), - bootstrapFd: flags.bootstrapFd ?? Option.none(), - autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), - logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), - } satisfies CliServerFlags; - const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; - const bootstrapEnvelope = - bootstrapFd !== undefined - ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) - : Option.none(); - const bootstrap = Option.getOrUndefined(bootstrapEnvelope); - - const mode: RuntimeMode = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.mode, - Option.fromUndefinedOr(env.mode), - Option.fromUndefinedOr(bootstrap?.mode), - ), - () => "web", - ); - - const port = yield* Option.match( - resolveOptionPrecedence( - normalizedFlags.port, - Option.fromUndefinedOr(env.port), - Option.fromUndefinedOr(bootstrap?.port), - ), - { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); - } - return findAvailablePort(DEFAULT_PORT); - }, - }, - ); - const devUrl = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.devUrl, - Option.fromUndefinedOr(env.devUrl), - Option.fromUndefinedOr(bootstrap?.devUrl), - ), - () => undefined, - ); - const baseDir = yield* resolveBaseDir( - Option.getOrUndefined( - resolveOptionPrecedence( - normalizedFlags.baseDir, - Option.fromUndefinedOr(env.t3Home), - Option.fromUndefinedOr(bootstrap?.t3Home), - ), - ), - ); - const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); - const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); - yield* fs.makeDirectory(cwd, { recursive: true }); - const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - yield* ensureServerDirectories(derivedPaths); - const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( - derivedPaths.settingsPath, - ); - const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; - yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); - const startupPresentation = options?.startupPresentation ?? "browser"; - const isHeadlessStartup = startupPresentation === "headless"; - const noBrowser = Option.getOrElse( - resolveOptionPrecedence( - isHeadlessStartup ? Option.some(true) : Option.none(), - normalizedFlags.noBrowser, - Option.fromUndefinedOr(env.noBrowser), - Option.fromUndefinedOr(bootstrap?.noBrowser), - ), - () => mode === "desktop", - ); - const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; - const autoBootstrapProjectFromCwd = Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), - isHeadlessStartup ? Option.some(false) : Option.none(), - normalizedFlags.autoBootstrapProjectFromCwd, - Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), - Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), - ), - () => mode === "web", - ); - const logWebSocketEvents = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.logWebSocketEvents, - Option.fromUndefinedOr(env.logWebSocketEvents), - Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), - ), - () => Boolean(devUrl), - ); - const staticDir = devUrl ? undefined : yield* resolveStaticDir(); - const host = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.host, - Option.fromUndefinedOr(env.host), - Option.fromUndefinedOr(bootstrap?.host), - ), - () => (mode === "desktop" ? "127.0.0.1" : undefined), - ); - const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - - const config: ServerConfigShape = { - logLevel, - traceMinLevel: env.traceMinLevel, - traceTimingEnabled: env.traceTimingEnabled, - traceBatchWindowMs: env.traceBatchWindowMs, - traceMaxBytes: env.traceMaxBytes, - traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: - env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl, - otlpMetricsUrl: - env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl, - otlpExportIntervalMs: env.otlpExportIntervalMs, - otlpServiceName: env.otlpServiceName, - mode, - port, - cwd, - baseDir, - ...derivedPaths, - serverTracePath, - host, - staticDir, - devUrl, - noBrowser, - startupPresentation, - desktopBootstrapToken, - autoBootstrapProjectFromCwd, - logWebSocketEvents, - }; - - return config; - }); - -const resolveCliAuthConfig = ( - flags: CliAuthLocationFlags, - cliLogLevel: Option.Option, -) => - resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - host: Option.none(), - baseDir: flags.baseDir, - cwd: Option.none(), - devUrl: flags.devUrl ?? Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - }, - cliLogLevel, - ); - -const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; - -const parseDurationInput = (value: string): Duration.Duration | null => { - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - const shorthand = DurationShorthandPattern.exec(trimmed); - const normalizedInput = shorthand?.groups - ? (() => { - const amountText = shorthand.groups.value; - const unitText = shorthand.groups.unit; - if (typeof amountText !== "string" || typeof unitText !== "string") { - return null; - } - - const amount = Number.parseInt(amountText, 10); - if (!Number.isFinite(amount)) return null; - - switch (unitText.toLowerCase()) { - case "ms": - return `${amount} millis`; - case "s": - return `${amount} seconds`; - case "m": - return `${amount} minutes`; - case "h": - return `${amount} hours`; - case "d": - return `${amount} days`; - case "w": - return `${amount} weeks`; - default: - return null; - } - })() - : (trimmed as Duration.Input); - - if (normalizedInput === null) return null; - - const decoded = Duration.fromInput(normalizedInput as Duration.Input); - return Option.isSome(decoded) ? decoded.value : null; -}; - -const DurationFromString = Schema.String.pipe( - Schema.decodeTo( - Schema.Duration, - SchemaTransformation.transformOrFail({ - decode: (value) => { - const duration = parseDurationInput(value); - if (duration !== null) { - return Effect.succeed(duration); - } - return Effect.fail( - new SchemaIssue.InvalidValue(Option.some(value), { - message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", - }), - ); - }, - encode: (duration) => Effect.succeed(Duration.format(duration)), - }), - ), -); - -const runWithAuthControlPlane = ( - flags: CliAuthLocationFlags, - run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect, - options?: { - readonly quietLogs?: boolean; - }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; - return yield* Effect.gen(function* () { - const authControlPlane = yield* AuthControlPlane; - return yield* run(authControlPlane); - }).pipe( - Effect.provide( - Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( - Layer.provide(Layer.succeed(ServerConfig, config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ), - ), - ); - }); - -type ProjectMutationTarget = { - readonly id: ProjectId; - readonly title: string; - readonly workspaceRoot: string; -}; - -type ProjectCommandExecutionMode = "live" | "offline"; -type ProjectCliDispatchCommand = Extract< - ClientOrchestrationCommand, - { type: "project.create" | "project.meta.update" | "project.delete" } ->; - -const ProjectCliRuntimeLive = Layer.mergeAll( - WorkspacePathsLive, - OrchestrationLayerLive.pipe( - Layer.provideMerge(RepositoryIdentityResolverLive), - Layer.provideMerge(SqlitePersistenceLayerLive), - ), -); - -const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); -const OrchestrationHttpErrorResponse = Schema.Struct({ - error: Schema.String, -}); - -const withProjectCliSessionToken = ( - authControlPlane: AuthControlPlaneShape, - run: (token: string) => Effect.Effect, -) => - Effect.acquireUseRelease( - authControlPlane.issueSession({ - role: "owner", - label: "t3 project cli", - }), - (issued) => run(issued.token), - (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), - ); - -const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => - effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); - -const runLiveServerRequest = ( - request: HttpClientRequest.HttpClientRequest, - handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, -) => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const response = yield* httpClient.execute(request); - return yield* handle(response); - }).pipe(withProjectCliLiveServerTimeout); - -const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => - HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); - -const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => - HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( - Effect.map((body) => body.error), - Effect.catch(() => Effect.succeed(null)), - Effect.map((body) => { - if (typeof body === "string" && body.trim().length > 0) { - return body; - } - return `Server request failed with status ${response.status}.`; - }), - ); - -const normalizeWorkspaceRootForProjectCommand = Effect.fn( - "normalizeWorkspaceRootForProjectCommand", -)(function* (workspaceRoot: string) { - const workspacePaths = yield* WorkspacePaths; - return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); -}); - -const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( - workspaceRoot: string, - explicitTitle?: string, -) { - if (explicitTitle !== undefined) { - const trimmed = explicitTitle.trim(); - if (trimmed.length > 0) { - return trimmed; - } - return yield* Effect.fail(new Error("Project title cannot be empty.")); - } - - const path = yield* Path.Path; - const basename = path.basename(workspaceRoot).trim(); - return basename.length > 0 ? basename : "project"; -}); - -const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { - readonly snapshot: OrchestrationReadModel; - readonly identifier: string; -}) { - const trimmedIdentifier = input.identifier.trim(); - if (trimmedIdentifier.length === 0) { - return yield* Effect.fail(new Error("Project identifier cannot be empty.")); - } - - const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); - const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); - if (exactIdMatch) { - return { - id: exactIdMatch.id, - title: exactIdMatch.title, - workspaceRoot: exactIdMatch.workspaceRoot, - } satisfies ProjectMutationTarget; - } - - const normalizedWorkspaceRootResult = yield* Effect.exit( - normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), - ); - const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) - ? normalizedWorkspaceRootResult.value - : null; - - const exactWorkspaceMatch = - normalizedWorkspaceRoot === null - ? undefined - : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); - - const resolved = exactWorkspaceMatch; - if (!resolved) { - return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); - } - - return { - id: resolved.id, - title: resolved.title, - workspaceRoot: resolved.workspaceRoot, - } satisfies ProjectMutationTarget; -}); - -const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => - runLiveServerRequest( - HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(bearerToken), - ), - HttpClientResponse.matchStatus({ - "2xx": decodeOrchestrationReadModelResponse, - orElse: (response) => - readErrorMessageFromResponse(response).pipe( - Effect.flatMap((message) => Effect.fail(new Error(message))), - ), - }), - ); - -const dispatchLiveOrchestrationCommand = ( - origin: string, - bearerToken: string, - command: ProjectCliDispatchCommand, -) => - HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(bearerToken), - HttpClientRequest.bodyJson(command), - Effect.flatMap((request) => - runLiveServerRequest( - request, - HttpClientResponse.matchStatus({ - "2xx": () => Effect.void, - orElse: (response) => - readErrorMessageFromResponse(response).pipe( - Effect.flatMap((message) => Effect.fail(new Error(message))), - ), - }), - ), - ), - ); - -const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - return yield* projectionSnapshotQuery.getSnapshot(); -}); - -const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( - function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { - const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); - if (Option.isNone(runtimeState)) { - return Option.none<{ readonly origin: string }>(); - } - - const attempt = withProjectCliSessionToken(authControlPlane, (token) => - fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( - Effect.as({ - origin: runtimeState.value.origin, - }), - ), - ); - - const attempted = yield* Effect.exit(attempt); - if (Exit.isSuccess(attempted)) { - return Option.some(attempted.value); - } - - yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); - return Option.none<{ readonly origin: string }>(); - }, -); - -const runProjectMutation = Effect.fn("runProjectMutation")(function* ( - flags: CliAuthLocationFlags, - run: (input: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - readonly mode: ProjectCommandExecutionMode; - }) => Effect.Effect< - string, - Error, - FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths - >, -) { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = config.logLevel; - - return yield* Effect.gen(function* () { - const authControlPlane = yield* AuthControlPlane; - const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); - - if (Option.isSome(liveMode)) { - return yield* withProjectCliSessionToken(authControlPlane, (token) => - Effect.gen(function* () { - const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); - const output = yield* run({ - snapshot, - dispatch: (command) => - dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), - mode: "live", - }); - yield* Console.log(output); - }), - ); - } - - const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( - Layer.provide(Layer.succeed(ServerConfig, config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ); - - return yield* Effect.gen(function* () { - const snapshot = yield* getOfflineSnapshot(); - const orchestrationEngine = yield* OrchestrationEngineService; - const output = yield* run({ - snapshot, - dispatch: (command) => orchestrationEngine.dispatch(command), - mode: "offline", - }); - yield* Console.log(output); - }).pipe(Effect.provide(offlineRuntimeLayer)); - }).pipe( - Effect.provide( - Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( - Layer.provideMerge(FetchHttpClient.layer), - Layer.provide(Layer.succeed(ServerConfig, config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ), - ), - ); -}); - -const sharedServerLocationFlags = { - baseDir: baseDirFlag, - devUrl: devUrlFlag, -} as const; - -const projectLocationFlags = { - baseDir: baseDirFlag, -} as const; - -const sharedServerCommandFlags = { - mode: modeFlag, - port: portFlag, - host: hostFlag, - baseDir: baseDirFlag, - cwd: Argument.string("cwd").pipe( - Argument.withDescription( - "Working directory for provider sessions (defaults to the current directory).", - ), - Argument.optional, - ), - devUrl: devUrlFlag, - noBrowser: noBrowserFlag, - bootstrapFd: bootstrapFdFlag, - autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, - logWebSocketEvents: logWebSocketEventsFlag, -} as const; - -const authLocationFlags = sharedServerLocationFlags; - -const ttlFlag = Flag.string("ttl").pipe( - Flag.withSchema(DurationFromString), - Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), - Flag.optional, -); - -const jsonFlag = Flag.boolean("json").pipe( - Flag.withDescription("Emit JSON instead of human-readable output."), - Flag.withDefault(false), -); - -const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( - Flag.withDescription("Role for the issued bearer session."), - Flag.withDefault("owner"), -); - -const labelFlag = Flag.string("label").pipe( - Flag.withDescription("Optional human-readable label."), - Flag.optional, -); - -const subjectFlag = Flag.string("subject").pipe( - Flag.withDescription("Optional session subject."), - Flag.optional, -); - -const baseUrlFlag = Flag.string("base-url").pipe( - Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), - Flag.optional, -); - -const tokenOnlyFlag = Flag.boolean("token-only").pipe( - Flag.withDescription("Print only the issued bearer token."), - Flag.withDefault(false), -); - -const pairingCreateCommand = Command.make("create", { - ...authLocationFlags, - ttl: ttlFlag, - label: labelFlag, - baseUrl: baseUrlFlag, - json: jsonFlag, -}).pipe( - Command.withDescription("Issue a new client pairing token."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const issued = yield* authControlPlane.createPairingLink({ - role: "client", - subject: "one-time-token", - ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), - ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), - }); - const output = formatIssuedPairingCredential(issued, { - json: flags.json, - ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), - }); - yield* Console.log(output); - }), - { - quietLogs: flags.json, - }, - ), - ), -); - -const pairingListCommand = Command.make("list", { - ...authLocationFlags, - json: jsonFlag, -}).pipe( - Command.withDescription("List active client pairing tokens without revealing their secrets."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); - yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); - }), - { - quietLogs: flags.json, - }, - ), - ), -); - -const pairingRevokeCommand = Command.make("revoke", { - ...authLocationFlags, - id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), -}).pipe( - Command.withDescription("Revoke an active client pairing token."), - Command.withHandler((flags) => - runWithAuthControlPlane(flags, (authControlPlane) => - Effect.gen(function* () { - const revoked = yield* authControlPlane.revokePairingLink(flags.id); - yield* Console.log( - revoked - ? `Revoked pairing credential ${flags.id}.\n` - : `No active pairing credential found for ${flags.id}.\n`, - ); - }), - ), - ), -); - -const pairingCommand = Command.make("pairing").pipe( - Command.withDescription("Manage one-time client pairing tokens."), - Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), -); - -const sessionIssueCommand = Command.make("issue", { - ...authLocationFlags, - ttl: ttlFlag, - role: sessionRoleFlag, - label: labelFlag, - subject: subjectFlag, - tokenOnly: tokenOnlyFlag, - json: jsonFlag, -}).pipe( - Command.withDescription("Issue a bearer session token for headless or remote clients."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const issued = yield* authControlPlane.issueSession({ - role: flags.role, - ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), - ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), - ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), - }); - yield* Console.log( - formatIssuedSession(issued, { - json: flags.json, - tokenOnly: flags.tokenOnly, - }), - ); - }), - { - quietLogs: flags.json || flags.tokenOnly, - }, - ), - ), -); - -const sessionListCommand = Command.make("list", { - ...authLocationFlags, - json: jsonFlag, -}).pipe( - Command.withDescription("List active sessions without revealing bearer tokens."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const sessions = yield* authControlPlane.listSessions(); - yield* Console.log(formatSessionList(sessions, { json: flags.json })); - }), - { - quietLogs: flags.json, - }, - ), - ), -); - -const sessionRevokeCommand = Command.make("revoke", { - ...authLocationFlags, - sessionId: Argument.string("session-id").pipe( - Argument.withDescription("Session id to revoke."), - Argument.withSchema(AuthSessionId), - ), -}).pipe( - Command.withDescription("Revoke an active session."), - Command.withHandler((flags) => - runWithAuthControlPlane(flags, (authControlPlane) => - Effect.gen(function* () { - const revoked = yield* authControlPlane.revokeSession(flags.sessionId); - yield* Console.log( - revoked - ? `Revoked session ${flags.sessionId}.\n` - : `No active session found for ${flags.sessionId}.\n`, - ); - }), - ), - ), -); - -const sessionCommand = Command.make("session").pipe( - Command.withDescription("Manage bearer sessions."), - Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), -); - -const authCommand = Command.make("auth").pipe( - Command.withDescription("Manage the local auth control plane for headless deployments."), - Command.withSubcommands([pairingCommand, sessionCommand]), -); - -const projectAddCommand = Command.make("add", { - ...projectLocationFlags, - workspaceRoot: Argument.string("path").pipe( - Argument.withDescription("Workspace root to add as a project."), - ), - title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), -}).pipe( - Command.withDescription("Add a project."), - Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectAddMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); - const existingProject = snapshot.projects.find( - (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, - ); - if (existingProject) { - return yield* Effect.fail( - new Error(`An active project already exists for '${workspaceRoot}'.`), - ); - } - - const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); - const projectId = ProjectId.make(crypto.randomUUID()); - yield* dispatch({ - type: "project.create", - commandId: CommandId.make(crypto.randomUUID()), - projectId, - title, - workspaceRoot, - defaultModelSelection: getAutoBootstrapDefaultModelSelection(), - createdAt: new Date().toISOString(), - }); - return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; - }), - ), - ), -); - -const projectRemoveCommand = Command.make("remove", { - ...projectLocationFlags, - project: Argument.string("project").pipe( - Argument.withDescription("Project id or workspace root to remove."), - ), -}).pipe( - Command.withDescription("Remove a project."), - Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectRemoveMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const project = yield* findActiveProjectTarget({ - snapshot, - identifier: flags.project, - }); - yield* dispatch({ - type: "project.delete", - commandId: CommandId.make(crypto.randomUUID()), - projectId: project.id, - }); - return `Removed project ${project.id} (${project.title}).`; - }), - ), - ), -); - -const projectRenameCommand = Command.make("rename", { - ...projectLocationFlags, - project: Argument.string("project").pipe( - Argument.withDescription("Project id or workspace root to rename."), - ), - title: Argument.string("title").pipe(Argument.withDescription("New project title.")), -}).pipe( - Command.withDescription("Rename a project."), - Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectRenameMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const project = yield* findActiveProjectTarget({ - snapshot, - identifier: flags.project, - }); - const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); - if (nextTitle === project.title) { - return `Project ${project.id} is already named ${nextTitle}.`; - } - - yield* dispatch({ - type: "project.meta.update", - commandId: CommandId.make(crypto.randomUUID()), - projectId: project.id, - title: nextTitle, - }); - return `Renamed project ${project.id} to ${nextTitle}.`; - }), - ), - ), -); - -const projectCommand = Command.make("project").pipe( - Command.withDescription("Manage projects."), - Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), -); - -const runServerCommand = ( - flags: CliServerFlags, - options?: { - readonly startupPresentation?: StartupPresentation; - readonly forceAutoBootstrapProjectFromCwd?: boolean; - }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveServerConfig(flags, logLevel, options); - return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); - }); - -const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), -); - -const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( - Command.withDescription( - "Run the T3 Code server without opening a browser and print headless pairing details.", - ), - Command.withHandler((flags) => - runServerCommand(flags, { - startupPresentation: "headless", - forceAutoBootstrapProjectFromCwd: false, - }), - ), -); - -export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), - Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), -); diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts new file mode 100644 index 00000000000..d54731b4a24 --- /dev/null +++ b/apps/server/src/cli/auth.ts @@ -0,0 +1,247 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; + +import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; +import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; +import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; +import { + formatIssuedPairingCredential, + formatIssuedSession, + formatPairingCredentialList, + formatSessionList, +} from "../cliAuthFormat.ts"; +import { ServerConfig } from "../config.ts"; +import { + authLocationFlags, + type CliAuthLocationFlags, + DurationFromString, + resolveCliAuthConfig, +} from "./config.ts"; + +const runWithAuthControlPlane = ( + flags: CliAuthLocationFlags, + run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect, + options?: { + readonly quietLogs?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + return yield* run(authControlPlane); + }).pipe( + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); + }); + +const ttlFlag = Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), + Flag.optional, +); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( + Flag.withDescription("Role for the issued bearer session."), + Flag.withDefault("owner"), +); + +const labelFlag = Flag.string("label").pipe( + Flag.withDescription("Optional human-readable label."), + Flag.optional, +); + +const subjectFlag = Flag.string("subject").pipe( + Flag.withDescription("Optional session subject."), + Flag.optional, +); + +const baseUrlFlag = Flag.string("base-url").pipe( + Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), + Flag.optional, +); + +const tokenOnlyFlag = Flag.boolean("token-only").pipe( + Flag.withDescription("Print only the issued bearer token."), + Flag.withDefault(false), +); + +const pairingCreateCommand = Command.make("create", { + ...authLocationFlags, + ttl: ttlFlag, + label: labelFlag, + baseUrl: baseUrlFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a new client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.createPairingLink({ + role: "client", + subject: "one-time-token", + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + }); + const output = formatIssuedPairingCredential(issued, { + json: flags.json, + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + }); + yield* Console.log(output); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active client pairing tokens without revealing their secrets."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); + yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), +}).pipe( + Command.withDescription("Revoke an active client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokePairingLink(flags.id); + yield* Console.log( + revoked + ? `Revoked pairing credential ${flags.id}.\n` + : `No active pairing credential found for ${flags.id}.\n`, + ); + }), + ), + ), +); + +const pairingCommand = Command.make("pairing").pipe( + Command.withDescription("Manage one-time client pairing tokens."), + Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), +); + +const sessionIssueCommand = Command.make("issue", { + ...authLocationFlags, + ttl: ttlFlag, + role: sessionRoleFlag, + label: labelFlag, + subject: subjectFlag, + tokenOnly: tokenOnlyFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a bearer session token for headless or remote clients."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.issueSession({ + role: flags.role, + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), + }); + yield* Console.log( + formatIssuedSession(issued, { + json: flags.json, + tokenOnly: flags.tokenOnly, + }), + ); + }), + { + quietLogs: flags.json || flags.tokenOnly, + }, + ), + ), +); + +const sessionListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active sessions without revealing bearer tokens."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const sessions = yield* authControlPlane.listSessions(); + yield* Console.log(formatSessionList(sessions, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const sessionRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + sessionId: Argument.string("session-id").pipe( + Argument.withDescription("Session id to revoke."), + Argument.withSchema(AuthSessionId), + ), +}).pipe( + Command.withDescription("Revoke an active session."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokeSession(flags.sessionId); + yield* Console.log( + revoked + ? `Revoked session ${flags.sessionId}.\n` + : `No active session found for ${flags.sessionId}.\n`, + ); + }), + ), + ), +); + +const sessionCommand = Command.make("session").pipe( + Command.withDescription("Manage bearer sessions."), + Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), +); + +export const authCommand = Command.make("auth").pipe( + Command.withDescription("Manage the local auth control plane for headless deployments."), + Command.withSubcommands([pairingCommand, sessionCommand]), +); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli/config.test.ts similarity index 77% rename from apps/server/src/cli-config.test.ts rename to apps/server/src/cli/config.test.ts index 6fa6e0c96b6..9e73773d5a5 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,12 +1,38 @@ -import os from "node:os"; +import NodeOS from "node:os"; import { assert, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; +import * as ConfigProvider from "effect/ConfigProvider"; +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 { NetService } from "@t3tools/shared/Net"; +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { deriveServerPaths } from "./config"; -import { resolveServerConfig } from "./cli"; +import { deriveServerPaths } from "../config.ts"; +import { resolveServerConfig } from "./config.ts"; + +const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + +const makeDesktopBootstrap = ( + overrides: Partial = {}, +): DesktopBackendBootstrapValue => ({ + mode: "desktop", + noBrowser: true, + port: 4888, + t3Home: "/tmp/t3-bootstrap-home", + host: "127.0.0.1", + desktopBootstrapToken: "desktop-bootstrap-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + ...overrides, +}); it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { @@ -21,10 +47,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpServiceName: "t3-server", } as const; - const openBootstrapFd = Effect.fn(function* (payload: Record) { + const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); - yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); + const encoded = yield* encodeDesktopBootstrap(payload); + yield* fs.writeFileString(filePath, `${encoded}\n`); const { fd } = yield* fs.open(filePath, { flag: "r" }); return fd; }); @@ -32,7 +59,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const baseDir = join(os.tmpdir(), "t3-cli-config-env-base"); + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-env-base"); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); const resolved = yield* resolveServerConfig( { @@ -46,6 +73,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -87,6 +116,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -94,7 +125,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("uses CLI flags when provided", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base"); + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-flags-base"); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( { @@ -108,6 +139,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), + tailscaleServeEnabled: Option.some(true), + tailscaleServePort: Option.some(8443), }, Option.some("Debug"), ).pipe( @@ -120,7 +153,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_MODE: "desktop", T3CODE_PORT: "4001", T3CODE_HOST: "0.0.0.0", - T3CODE_HOME: join(os.tmpdir(), "ignored-base"), + T3CODE_HOME: join(NodeOS.tmpdir(), "ignored-base"), VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "false", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", @@ -149,6 +182,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + tailscaleServeEnabled: true, + tailscaleServePort: 8443, }); }), ); @@ -156,12 +191,14 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); - const fd = yield* openBootstrapFd({ - noBrowser: true, - autoBootstrapProjectFromCwd: true, - logWebSocketEvents: true, - }); + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-false-flags"); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + noBrowser: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + ); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( @@ -176,6 +213,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(false), logWebSocketEvents: Option.some(false), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -209,9 +248,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: new URL("http://127.0.0.1:4173"), noBrowser: false, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -220,19 +261,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = "/tmp/t3-bootstrap-home"; - const fd = yield* openBootstrapFd({ - mode: "desktop", - port: 4888, - host: "127.0.0.2", - t3Home: baseDir, - devUrl: "http://127.0.0.1:5173", - noBrowser: true, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: true, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }); - const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + port: 4888, + host: "127.0.0.2", + t3Home: baseDir, + noBrowser: true, + desktopBootstrapToken: "desktop-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }), + ); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); const resolved = yield* resolveServerConfig( { @@ -246,6 +288,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -274,15 +318,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { baseDir, ...derivedPaths, host: "127.0.0.2", - staticDir: undefined, - devUrl: new URL("http://127.0.0.1:5173"), + staticDir: resolved.staticDir, + devUrl: undefined, noBrowser: true, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: false, - logWebSocketEvents: true, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); - assert.equal(join(baseDir, "dev"), resolved.stateDir); + assert.equal(join(baseDir, "userdata"), resolved.stateDir); }), ); @@ -305,6 +351,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -336,17 +384,18 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("applies flag then env precedence over bootstrap envelope values", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); - const fd = yield* openBootstrapFd({ - mode: "desktop", - port: 4888, - host: "127.0.0.2", - t3Home: "/tmp/t3-bootstrap-home", - devUrl: "http://127.0.0.1:5173", - noBrowser: false, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - }); + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-env-wins"); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + noBrowser: false, + desktopBootstrapToken: "desktop-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + ); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( @@ -361,6 +410,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.some("Debug"), ).pipe( @@ -396,9 +447,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -412,6 +465,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); yield* fs.writeFileString( derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off `${JSON.stringify({ observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -432,6 +486,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -463,6 +519,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -470,7 +528,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base"); + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-headless-base"); const derivedPaths = yield* deriveServerPaths(baseDir, undefined); const resolved = yield* resolveServerConfig( @@ -485,6 +543,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), { @@ -522,6 +582,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts new file mode 100644 index 00000000000..7182854e18c --- /dev/null +++ b/apps/server/src/cli/config.ts @@ -0,0 +1,465 @@ +import * as NetService from "@t3tools/shared/Net"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; +import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as LogLevel from "effect/LogLevel"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import { Argument, Flag } from "effect/unstable/cli"; + +import { readBootstrapEnvelope } from "../bootstrap.ts"; +import { + DEFAULT_PORT, + deriveServerPaths, + ensureServerDirectories, + resolveStaticDir, + RuntimeMode, + type ServerConfigShape, + type StartupPresentation, +} from "../config.ts"; +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; + +export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( + Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), + Flag.optional, +); +export const portFlag = Flag.integer("port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("Port for the HTTP/WebSocket server."), + Flag.optional, +); +export const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), + Flag.optional, +); +export const baseDirFlag = Flag.string("base-dir").pipe( + Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), + Flag.optional, +); +export const devUrlFlag = Flag.string("dev-url").pipe( + Flag.withSchema(Schema.URLFromString), + Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), + Flag.optional, +); +export const noBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Disable automatic browser opening."), + Flag.optional, +); +export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( + Flag.withSchema(Schema.Int), + Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), + Flag.optional, +); +export const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( + Flag.withDescription( + "Create a project for the current working directory on startup when missing.", + ), + Flag.optional, +); +export const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( + Flag.withDescription( + "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", + ), + Flag.withAlias("log-ws-events"), + Flag.optional, +); +export const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( + Flag.withDescription( + "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", + ), + Flag.optional, +); +export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), + Flag.optional, +); + +const EnvServerConfig = Config.all({ + logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), + traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), + traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), + traceFile: Config.string("T3CODE_TRACE_FILE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + 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)), + otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( + Config.withDefault(10_000), + ), + otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), + mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), +}); + +export interface CliServerFlags { + readonly mode: Option.Option; + readonly port: Option.Option; + readonly host: Option.Option; + readonly baseDir: Option.Option; + readonly cwd: Option.Option; + readonly devUrl: Option.Option; + readonly noBrowser: Option.Option; + readonly bootstrapFd: Option.Option; + readonly autoBootstrapProjectFromCwd: Option.Option; + readonly logWebSocketEvents: Option.Option; + readonly tailscaleServeEnabled: Option.Option; + readonly tailscaleServePort: Option.Option; +} + +export interface CliAuthLocationFlags { + readonly baseDir: Option.Option; + readonly devUrl?: Option.Option; +} + +export const sharedServerLocationFlags = { + baseDir: baseDirFlag, + devUrl: devUrlFlag, +} as const; + +export const projectLocationFlags = { + baseDir: baseDirFlag, +} as const; + +export const sharedServerCommandFlags = { + mode: modeFlag, + port: portFlag, + host: hostFlag, + baseDir: baseDirFlag, + cwd: Argument.string("cwd").pipe( + Argument.withDescription( + "Working directory for provider sessions (defaults to the current directory).", + ), + Argument.optional, + ), + devUrl: devUrlFlag, + noBrowser: noBrowserFlag, + bootstrapFd: bootstrapFdFlag, + autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, + logWebSocketEvents: logWebSocketEventsFlag, + tailscaleServeEnabled: tailscaleServeFlag, + tailscaleServePort: tailscaleServePortFlag, +} as const; + +export const authLocationFlags = sharedServerLocationFlags; + +const resolveOptionPrecedence = ( + ...values: ReadonlyArray> +): Option.Option => Option.firstSomeOf(values); + +const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } + + const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); + return parsePersistedServerObservabilitySettings(raw); +}); + +export const resolveServerConfig = ( + flags: CliServerFlags, + cliLogLevel: Option.Option, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, +) => + Effect.gen(function* () { + const { findAvailablePort } = yield* NetService.NetService; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const env = yield* EnvServerConfig; + const normalizedFlags = { + mode: flags.mode ?? Option.none(), + port: flags.port ?? Option.none(), + host: flags.host ?? Option.none(), + baseDir: flags.baseDir ?? Option.none(), + cwd: flags.cwd ?? Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: flags.noBrowser ?? Option.none(), + bootstrapFd: flags.bootstrapFd ?? Option.none(), + autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), + logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), + tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), + tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + } satisfies CliServerFlags; + const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd) + : Option.none(); + const bootstrap = Option.getOrUndefined(bootstrapEnvelope); + + const mode: RuntimeMode = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.mode, + Option.fromUndefinedOr(env.mode), + Option.fromUndefinedOr(bootstrap?.mode), + ), + () => "web", + ); + + const port = yield* Option.match( + resolveOptionPrecedence( + normalizedFlags.port, + Option.fromUndefinedOr(env.port), + Option.fromUndefinedOr(bootstrap?.port), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, + }, + ); + const devUrl = Option.getOrElse( + resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), + () => undefined, + ); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.baseDir, + Option.fromUndefinedOr(env.t3Home), + Option.fromUndefinedOr(bootstrap?.t3Home), + ), + ), + ); + const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); + const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); + yield* fs.makeDirectory(cwd, { recursive: true }); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + yield* ensureServerDirectories(derivedPaths); + const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( + derivedPaths.settingsPath, + ); + const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; + yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); + const startupPresentation = options?.startupPresentation ?? "browser"; + const isHeadlessStartup = startupPresentation === "headless"; + const noBrowser = Option.getOrElse( + resolveOptionPrecedence( + isHeadlessStartup ? Option.some(true) : Option.none(), + normalizedFlags.noBrowser, + Option.fromUndefinedOr(env.noBrowser), + Option.fromUndefinedOr(bootstrap?.noBrowser), + ), + () => mode === "desktop", + ); + const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const autoBootstrapProjectFromCwd = Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), + isHeadlessStartup ? Option.some(false) : Option.none(), + normalizedFlags.autoBootstrapProjectFromCwd, + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + ), + () => mode === "web", + ); + const logWebSocketEvents = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.logWebSocketEvents, + Option.fromUndefinedOr(env.logWebSocketEvents), + ), + () => Boolean(devUrl), + ); + const tailscaleServeEnabled = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServeEnabled, + Option.fromUndefinedOr(env.tailscaleServeEnabled), + Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), + ), + () => false, + ); + const tailscaleServePort = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServePort, + Option.fromUndefinedOr(env.tailscaleServePort), + Option.fromUndefinedOr(bootstrap?.tailscaleServePort), + ), + () => 443, + ); + const staticDir = devUrl ? undefined : yield* resolveStaticDir(); + const host = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.host, + Option.fromUndefinedOr(env.host), + Option.fromUndefinedOr(bootstrap?.host), + ), + () => (mode === "desktop" ? "127.0.0.1" : undefined), + ); + const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); + + const config: ServerConfigShape = { + logLevel, + traceMinLevel: env.traceMinLevel, + traceTimingEnabled: env.traceTimingEnabled, + traceBatchWindowMs: env.traceBatchWindowMs, + traceMaxBytes: env.traceMaxBytes, + traceMaxFiles: env.traceMaxFiles, + otlpTracesUrl: + env.otlpTracesUrl ?? + bootstrap?.otlpTracesUrl ?? + persistedObservabilitySettings.otlpTracesUrl, + otlpMetricsUrl: + env.otlpMetricsUrl ?? + bootstrap?.otlpMetricsUrl ?? + persistedObservabilitySettings.otlpMetricsUrl, + otlpExportIntervalMs: env.otlpExportIntervalMs, + otlpServiceName: env.otlpServiceName, + mode, + port, + cwd, + baseDir, + ...derivedPaths, + serverTracePath, + host, + staticDir, + devUrl, + noBrowser, + startupPresentation, + desktopBootstrapToken, + autoBootstrapProjectFromCwd, + logWebSocketEvents, + tailscaleServeEnabled, + tailscaleServePort, + }; + + return config; + }); + +export const resolveCliAuthConfig = ( + flags: CliAuthLocationFlags, + cliLogLevel: Option.Option, +) => + resolveServerConfig( + { + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: flags.baseDir, + cwd: Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + cliLogLevel, + ); + +const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; + +const parseDurationInput = (value: string): Duration.Duration | null => { + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + const shorthand = DurationShorthandPattern.exec(trimmed); + const normalizedInput = shorthand?.groups + ? (() => { + const amountText = shorthand.groups.value; + const unitText = shorthand.groups.unit; + if (typeof amountText !== "string" || typeof unitText !== "string") { + return null; + } + + const amount = Number.parseInt(amountText, 10); + if (!Number.isFinite(amount)) return null; + + switch (unitText.toLowerCase()) { + case "ms": + return `${amount} millis`; + case "s": + return `${amount} seconds`; + case "m": + return `${amount} minutes`; + case "h": + return `${amount} hours`; + case "d": + return `${amount} days`; + case "w": + return `${amount} weeks`; + default: + return null; + } + })() + : (trimmed as Duration.Input); + + if (normalizedInput === null) return null; + + const decoded = Duration.fromInput(normalizedInput as Duration.Input); + return Option.isSome(decoded) ? decoded.value : null; +}; + +export const DurationFromString = Schema.String.pipe( + Schema.decodeTo( + Schema.Duration, + SchemaTransformation.transformOrFail({ + decode: (value) => { + const duration = parseDurationInput(value); + if (duration !== null) { + return Effect.succeed(duration); + } + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(value), { + message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", + }), + ); + }, + encode: (duration) => Effect.succeed(Duration.format(duration)), + }), + ), +); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts new file mode 100644 index 00000000000..39772fcd061 --- /dev/null +++ b/apps/server/src/cli/project.ts @@ -0,0 +1,440 @@ +import { + CommandId, + OrchestrationReadModel, + ProjectId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; +import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; +import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; +import { ServerConfig, type ServerConfigShape } from "../config.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts"; +import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; +import { + clearPersistedServerRuntimeState, + readPersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +type ProjectMutationTarget = { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +}; + +type ProjectCommandExecutionMode = "live" | "offline"; +type ProjectCliDispatchCommand = Extract< + ClientOrchestrationCommand, + { type: "project.create" | "project.meta.update" | "project.delete" } +>; + +class ProjectCommandError extends Data.TaggedError("ProjectCommandError")<{ + readonly message: string; +}> {} + +const ProjectCliRuntimeLive = Layer.mergeAll( + WorkspacePathsLive, + OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceLayerLive), + ), +); + +const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); +const OrchestrationHttpErrorResponse = Schema.Struct({ + error: Schema.String, +}); + +const withProjectCliSessionToken = ( + authControlPlane: AuthControlPlaneShape, + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + authControlPlane.issueSession({ + role: "owner", + label: "t3 project cli", + }), + (issued) => run(issued.token), + (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + +const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => + effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); + +const runLiveServerRequest = ( + request: HttpClientRequest.HttpClientRequest, + handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, +) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + return yield* handle(response); + }).pipe(withProjectCliLiveServerTimeout); + +const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); + +const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( + Effect.map((body) => body.error), + Effect.catch(() => Effect.succeed(null)), + Effect.map((body) => { + if (typeof body === "string" && body.trim().length > 0) { + return body; + } + return `Server request failed with status ${response.status}.`; + }), + ); + +const normalizeWorkspaceRootForProjectCommand = Effect.fn( + "normalizeWorkspaceRootForProjectCommand", +)(function* (workspaceRoot: string) { + const workspacePaths = yield* WorkspacePaths; + return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); +}); + +const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( + workspaceRoot: string, + explicitTitle?: string, +) { + if (explicitTitle !== undefined) { + const trimmed = explicitTitle.trim(); + if (trimmed.length > 0) { + return trimmed; + } + return yield* new ProjectCommandError({ message: "Project title cannot be empty." }); + } + + const path = yield* Path.Path; + const basename = path.basename(workspaceRoot).trim(); + return basename.length > 0 ? basename : "project"; +}); + +const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { + readonly snapshot: OrchestrationReadModel; + readonly identifier: string; +}) { + const trimmedIdentifier = input.identifier.trim(); + if (trimmedIdentifier.length === 0) { + return yield* new ProjectCommandError({ message: "Project identifier cannot be empty." }); + } + + const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); + const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); + if (exactIdMatch) { + return { + id: exactIdMatch.id, + title: exactIdMatch.title, + workspaceRoot: exactIdMatch.workspaceRoot, + } satisfies ProjectMutationTarget; + } + + const normalizedWorkspaceRootResult = yield* Effect.exit( + normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), + ); + const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) + ? normalizedWorkspaceRootResult.value + : null; + + const exactWorkspaceMatch = + normalizedWorkspaceRoot === null + ? undefined + : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); + + const resolved = exactWorkspaceMatch; + if (!resolved) { + return yield* new ProjectCommandError({ + message: `No active project found for '${trimmedIdentifier}'.`, + }); + } + + return { + id: resolved.id, + title: resolved.title, + workspaceRoot: resolved.workspaceRoot, + } satisfies ProjectMutationTarget; +}); + +const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => + runLiveServerRequest( + HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + ), + HttpClientResponse.matchStatus({ + "2xx": decodeOrchestrationReadModelResponse, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))), + ), + }), + ); + +const dispatchLiveOrchestrationCommand = ( + origin: string, + bearerToken: string, + command: ProjectCliDispatchCommand, +) => + HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + HttpClientRequest.bodyJson(command), + Effect.flatMap((request) => + runLiveServerRequest( + request, + HttpClientResponse.matchStatus({ + "2xx": () => Effect.void, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))), + ), + }), + ), + ), + ); + +const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); +}); + +const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( + function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return Option.none<{ readonly origin: string }>(); + } + + const attempt = withProjectCliSessionToken(authControlPlane, (token) => + fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( + Effect.as({ + origin: runtimeState.value.origin, + }), + ), + ); + + const attempted = yield* Effect.exit(attempt); + if (Exit.isSuccess(attempted)) { + return Option.some(attempted.value); + } + + yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); + return Option.none<{ readonly origin: string }>(); + }, +); + +const runProjectMutation = Effect.fn("runProjectMutation")(function* ( + flags: CliAuthLocationFlags, + run: (input: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + readonly mode: ProjectCommandExecutionMode; + }) => Effect.Effect< + string, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths + >, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = config.logLevel; + + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); + + if (Option.isSome(liveMode)) { + return yield* withProjectCliSessionToken(authControlPlane, (token) => + Effect.gen(function* () { + const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); + const output = yield* run({ + snapshot, + dispatch: (command) => + dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), + mode: "live", + }); + yield* Console.log(output); + }), + ); + } + + const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + + return yield* Effect.gen(function* () { + const snapshot = yield* getOfflineSnapshot(); + const orchestrationEngine = yield* OrchestrationEngineService; + const output = yield* run({ + snapshot, + dispatch: (command) => orchestrationEngine.dispatch(command), + mode: "offline", + }); + yield* Console.log(output); + }).pipe(Effect.provide(offlineRuntimeLayer)); + }).pipe( + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( + Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); +}); + +const projectAddCommand = Command.make("add", { + ...projectLocationFlags, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Workspace root to add as a project."), + ), + title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), +}).pipe( + Command.withDescription("Add a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectAddMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); + const existingProject = snapshot.projects.find( + (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, + ); + if (existingProject) { + return yield* new ProjectCommandError({ + message: `An active project already exists for '${workspaceRoot}'.`, + }); + } + + const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); + const projectId = ProjectId.make(crypto.randomUUID()); + yield* dispatch({ + type: "project.create", + commandId: CommandId.make(crypto.randomUUID()), + projectId, + title, + workspaceRoot, + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; + }), + ), + ), +); + +const projectRemoveCommand = Command.make("remove", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to remove."), + ), +}).pipe( + Command.withDescription("Remove a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRemoveMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + yield* dispatch({ + type: "project.delete", + commandId: CommandId.make(crypto.randomUUID()), + projectId: project.id, + }); + return `Removed project ${project.id} (${project.title}).`; + }), + ), + ), +); + +const projectRenameCommand = Command.make("rename", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to rename."), + ), + title: Argument.string("title").pipe(Argument.withDescription("New project title.")), +}).pipe( + Command.withDescription("Rename a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRenameMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); + if (nextTitle === project.title) { + return `Project ${project.id} is already named ${nextTitle}.`; + } + + yield* dispatch({ + type: "project.meta.update", + commandId: CommandId.make(crypto.randomUUID()), + projectId: project.id, + title: nextTitle, + }); + return `Renamed project ${project.id} to ${nextTitle}.`; + }), + ), + ), +); + +export const projectCommand = Command.make("project").pipe( + Command.withDescription("Manage projects."), + Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), +); diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts new file mode 100644 index 00000000000..bc4b3f45706 --- /dev/null +++ b/apps/server/src/cli/server.ts @@ -0,0 +1,36 @@ +import * as Effect from "effect/Effect"; +import { Command, GlobalFlag } from "effect/unstable/cli"; + +import { ServerConfig, type StartupPresentation } from "../config.ts"; +import { runServer } from "../server.ts"; +import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; + +export const runServerCommand = ( + flags: CliServerFlags, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveServerConfig(flags, logLevel, options); + return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + }); + +export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), +); + +export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( + Command.withDescription( + "Run the T3 Code server without opening a browser and print headless pairing details.", + ), + Command.withHandler((flags) => + runServerCommand(flags, { + startupPresentation: "headless", + forceAutoBootstrapProjectFromCwd: false, + }), + ), +); diff --git a/apps/server/src/cliAuthFormat.test.ts b/apps/server/src/cliAuthFormat.test.ts index 017ced97e83..3ffaf6fba74 100644 --- a/apps/server/src/cliAuthFormat.test.ts +++ b/apps/server/src/cliAuthFormat.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest"; -import { DateTime } from "effect"; +import * as DateTime from "effect/DateTime"; import { formatIssuedPairingCredential, @@ -15,8 +15,8 @@ it("formats issued pairing credentials with the secret and optional pair URL", ( credential: "secret-pairing-token", role: "client", subject: "one-time-token", - createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), - expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), }, { baseUrl: "https://example.com", json: false }, ); @@ -34,8 +34,8 @@ it("formats pairing listings without exposing the secret token", () => { subject: "one-time-token", label: "Phone", role: "client", - createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), - expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), }, ], { json: false }, @@ -57,7 +57,7 @@ it("formats issued sessions with the bearer token but omits tokens from listings label: "deploy-bot", deviceType: "bot", }, - expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), }, { json: false }, ); @@ -75,8 +75,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings }, connected: false, current: false, - issuedAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), - expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + issuedAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), lastConnectedAt: null, }, ], diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 44356c5a8a9..4078860ff94 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,5 +1,5 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; -import { DateTime } from "effect"; +import * as DateTime from "effect/DateTime"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts deleted file mode 100644 index ab3b7a569de..00000000000 --- a/apps/server/src/codexAppServerManager.test.ts +++ /dev/null @@ -1,1070 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { randomUUID } from "node:crypto"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; - -import { - buildCodexInitializeParams, - CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, - CodexAppServerManager, - classifyCodexStderrLine, - isRecoverableThreadResumeError, - normalizeCodexModelSlug, - readCodexAccountSnapshot, - resolveCodexModelForAccount, -} from "./codexAppServerManager"; - -const asThreadId = (value: string): ThreadId => ThreadId.make(value); - -function createSendTurnHarness() { - const manager = new CodexAppServerManager(); - const context = { - session: { - provider: "codex", - status: "ready", - threadId: "thread_1", - runtimeMode: "full-access", - model: "gpt-5.3-codex", - resumeCursor: { threadId: "thread_1" }, - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - account: { - type: "unknown", - planType: null, - sparkEnabled: true, - }, - collabReceiverTurns: new Map(), - }; - - const requireSession = vi - .spyOn( - manager as unknown as { requireSession: (sessionId: string) => unknown }, - "requireSession", - ) - .mockReturnValue(context); - const sendRequest = vi - .spyOn( - manager as unknown as { sendRequest: (...args: unknown[]) => Promise }, - "sendRequest", - ) - .mockResolvedValue({ - turn: { - id: "turn_1", - }, - }); - const updateSession = vi - .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") - .mockImplementation(() => {}); - - return { manager, context, requireSession, sendRequest, updateSession }; -} - -function createThreadControlHarness() { - const manager = new CodexAppServerManager(); - const context = { - session: { - provider: "codex", - status: "ready", - threadId: "thread_1", - runtimeMode: "full-access", - model: "gpt-5.3-codex", - resumeCursor: { threadId: "thread_1" }, - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - collabReceiverTurns: new Map(), - }; - - const requireSession = vi - .spyOn( - manager as unknown as { requireSession: (sessionId: string) => unknown }, - "requireSession", - ) - .mockReturnValue(context); - const sendRequest = vi.spyOn( - manager as unknown as { sendRequest: (...args: unknown[]) => Promise }, - "sendRequest", - ); - const updateSession = vi - .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") - .mockImplementation(() => {}); - - return { manager, context, requireSession, sendRequest, updateSession }; -} - -function createPendingUserInputHarness() { - const manager = new CodexAppServerManager(); - const context = { - session: { - provider: "codex", - status: "ready", - threadId: "thread_1", - runtimeMode: "full-access", - model: "gpt-5.3-codex", - resumeCursor: { threadId: "thread_1" }, - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - pendingUserInputs: new Map([ - [ - ApprovalRequestId.make("req-user-input-1"), - { - requestId: ApprovalRequestId.make("req-user-input-1"), - jsonRpcId: 42, - threadId: asThreadId("thread_1"), - }, - ], - ]), - collabReceiverTurns: new Map(), - }; - - const requireSession = vi - .spyOn( - manager as unknown as { requireSession: (sessionId: string) => unknown }, - "requireSession", - ) - .mockReturnValue(context); - const writeMessage = vi - .spyOn(manager as unknown as { writeMessage: (...args: unknown[]) => void }, "writeMessage") - .mockImplementation(() => {}); - const emitEvent = vi - .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") - .mockImplementation(() => {}); - - return { manager, context, requireSession, writeMessage, emitEvent }; -} - -function createCollabNotificationHarness() { - const manager = new CodexAppServerManager(); - const context = { - session: { - provider: "codex", - status: "running", - threadId: asThreadId("thread_1"), - runtimeMode: "full-access", - model: "gpt-5.3-codex", - activeTurnId: "turn_parent", - resumeCursor: { threadId: "provider_parent" }, - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - account: { - type: "unknown", - planType: null, - sparkEnabled: true, - }, - pending: new Map(), - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - collabReceiverTurns: new Map(), - nextRequestId: 1, - stopping: false, - }; - - const emitEvent = vi - .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") - .mockImplementation(() => {}); - const updateSession = vi - .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") - .mockImplementation(() => {}); - - return { manager, context, emitEvent, updateSession }; -} - -describe("classifyCodexStderrLine", () => { - it("ignores empty lines", () => { - expect(classifyCodexStderrLine(" ")).toBeNull(); - }); - - it("ignores non-error structured codex logs", () => { - const line = - "2026-02-08T04:24:19.241256Z WARN codex_core::features: unknown feature key in config: skills"; - expect(classifyCodexStderrLine(line)).toBeNull(); - }); - - it("ignores known benign rollout path errors", () => { - const line = - "\u001b[2m2026-02-08T04:24:20.085687Z\u001b[0m \u001b[31mERROR\u001b[0m \u001b[2mcodex_core::rollout::list\u001b[0m: state db missing rollout path for thread 019c3b6c-46b8-7b70-ad23-82f824d161fb"; - expect(classifyCodexStderrLine(line)).toBeNull(); - }); - - it("keeps unknown structured errors", () => { - const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; - expect(classifyCodexStderrLine(line)).toEqual({ - message: line, - }); - }); - - it("keeps plain stderr messages", () => { - const line = "fatal: permission denied"; - expect(classifyCodexStderrLine(line)).toEqual({ - message: line, - }); - }); -}); - -describe("process stderr events", () => { - it("emits classified stderr lines as notifications", () => { - const manager = new CodexAppServerManager(); - const emitEvent = vi - .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") - .mockImplementation(() => {}); - - ( - manager as unknown as { - emitNotificationEvent: ( - context: { session: { threadId: ThreadId } }, - method: string, - message: string, - ) => void; - } - ).emitNotificationEvent( - { - session: { - threadId: asThreadId("thread-1"), - }, - }, - "process/stderr", - "fatal: permission denied", - ); - - expect(emitEvent).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "notification", - method: "process/stderr", - threadId: "thread-1", - message: "fatal: permission denied", - }), - ); - }); -}); - -describe("normalizeCodexModelSlug", () => { - it("maps 5.3 aliases to gpt-5.3-codex", () => { - expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); - expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); - }); - - it("prefers codex id when model differs", () => { - expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex"); - }); - - it("keeps non-aliased models as-is", () => { - expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); - expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); - }); -}); - -describe("isRecoverableThreadResumeError", () => { - it("matches not-found resume errors", () => { - expect( - isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")), - ).toBe(true); - }); - - it("ignores non-resume errors", () => { - expect( - isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")), - ).toBe(false); - }); - - it("ignores non-recoverable resume errors", () => { - expect( - isRecoverableThreadResumeError( - new Error("thread/resume failed: timed out waiting for server"), - ), - ).toBe(false); - }); -}); - -describe("readCodexAccountSnapshot", () => { - it("disables spark for chatgpt plus accounts", () => { - expect( - readCodexAccountSnapshot({ - type: "chatgpt", - email: "plus@example.com", - planType: "plus", - }), - ).toEqual({ - type: "chatgpt", - planType: "plus", - sparkEnabled: false, - }); - }); - - it("keeps spark enabled for chatgpt pro accounts", () => { - expect( - readCodexAccountSnapshot({ - type: "chatgpt", - email: "pro@example.com", - planType: "pro", - }), - ).toEqual({ - type: "chatgpt", - planType: "pro", - sparkEnabled: true, - }); - }); - - it("disables spark for api key accounts", () => { - expect( - readCodexAccountSnapshot({ - type: "apiKey", - }), - ).toEqual({ - type: "apiKey", - planType: null, - sparkEnabled: false, - }); - }); - - it("disables spark for unknown chatgpt plans", () => { - expect( - readCodexAccountSnapshot({ - type: "chatgpt", - email: "unknown@example.com", - }), - ).toEqual({ - type: "chatgpt", - planType: "unknown", - sparkEnabled: false, - }); - }); -}); - -describe("resolveCodexModelForAccount", () => { - it("falls back from spark to default for unsupported chatgpt plans", () => { - expect( - resolveCodexModelForAccount("gpt-5.3-codex-spark", { - type: "chatgpt", - planType: "plus", - sparkEnabled: false, - }), - ).toBe("gpt-5.3-codex"); - }); - - it("keeps spark for supported plans", () => { - expect( - resolveCodexModelForAccount("gpt-5.3-codex-spark", { - type: "chatgpt", - planType: "pro", - sparkEnabled: true, - }), - ).toBe("gpt-5.3-codex-spark"); - }); - - it("falls back from spark to default for api key auth", () => { - expect( - resolveCodexModelForAccount("gpt-5.3-codex-spark", { - type: "apiKey", - planType: null, - sparkEnabled: false, - }), - ).toBe("gpt-5.3-codex"); - }); -}); - -describe("startSession", () => { - it("enables Codex experimental api capabilities during initialize", () => { - expect(buildCodexInitializeParams()).toEqual({ - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); - }); - - it("emits session/startFailed when resolving cwd throws before process launch", async () => { - const manager = new CodexAppServerManager(); - const events: Array<{ method: string; kind: string; message?: string }> = []; - manager.on("event", (event) => { - events.push({ - method: event.method, - kind: event.kind, - ...(event.message ? { message: event.message } : {}), - }); - }); - - const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { - throw new Error("cwd missing"); - }); - try { - await expect( - manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "codex", - binaryPath: "codex", - runtimeMode: "full-access", - }), - ).rejects.toThrow("cwd missing"); - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - method: "session/startFailed", - kind: "error", - message: "cwd missing", - }); - } finally { - processCwd.mockRestore(); - manager.stopAll(); - } - }); - - it("fails fast with an upgrade message when codex is below the minimum supported version", async () => { - const manager = new CodexAppServerManager(); - const events: Array<{ method: string; kind: string; message?: string }> = []; - manager.on("event", (event) => { - events.push({ - method: event.method, - kind: event.kind, - ...(event.message ? { message: event.message } : {}), - }); - }); - - const versionCheck = vi - .spyOn( - manager as unknown as { - assertSupportedCodexCliVersion: (input: { - binaryPath: string; - cwd: string; - homePath?: string; - }) => void; - }, - "assertSupportedCodexCliVersion", - ) - .mockImplementation(() => { - throw new Error( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - }); - - try { - await expect( - manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "codex", - binaryPath: "codex", - runtimeMode: "full-access", - }), - ).rejects.toThrow( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - expect(versionCheck).toHaveBeenCalledTimes(1); - expect(events).toEqual([ - { - method: "session/startFailed", - kind: "error", - message: - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - }, - ]); - } finally { - versionCheck.mockRestore(); - manager.stopAll(); - } - }); -}); - -describe("sendTurn", () => { - it("sends text and image user input items to turn/start", async () => { - const { manager, context, requireSession, sendRequest, updateSession } = - createSendTurnHarness(); - - const result = await manager.sendTurn({ - threadId: asThreadId("thread_1"), - input: "Inspect this image", - attachments: [ - { - type: "image", - url: "data:image/png;base64,AAAA", - }, - ], - model: "gpt-5.3", - serviceTier: "fast", - effort: "high", - }); - - expect(result).toEqual({ - threadId: "thread_1", - turnId: "turn_1", - resumeCursor: { threadId: "thread_1" }, - }); - expect(requireSession).toHaveBeenCalledWith("thread_1"); - expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { - threadId: "thread_1", - input: [ - { - type: "text", - text: "Inspect this image", - text_elements: [], - }, - { - type: "image", - url: "data:image/png;base64,AAAA", - }, - ], - model: "gpt-5.3-codex", - serviceTier: "fast", - effort: "high", - }); - expect(updateSession).toHaveBeenCalledWith(context, { - status: "running", - activeTurnId: "turn_1", - resumeCursor: { threadId: "thread_1" }, - }); - }); - - it("supports image-only turns", async () => { - const { manager, context, sendRequest } = createSendTurnHarness(); - - await manager.sendTurn({ - threadId: asThreadId("thread_1"), - attachments: [ - { - type: "image", - url: "data:image/png;base64,BBBB", - }, - ], - }); - - expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { - threadId: "thread_1", - input: [ - { - type: "image", - url: "data:image/png;base64,BBBB", - }, - ], - model: "gpt-5.3-codex", - }); - }); - - it("passes Codex plan mode as a collaboration preset on turn/start", async () => { - const { manager, context, sendRequest } = createSendTurnHarness(); - - await manager.sendTurn({ - threadId: asThreadId("thread_1"), - input: "Plan the work", - interactionMode: "plan", - }); - - expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { - threadId: "thread_1", - input: [ - { - type: "text", - text: "Plan the work", - text_elements: [], - }, - ], - model: "gpt-5.3-codex", - collaborationMode: { - mode: "plan", - settings: { - model: "gpt-5.3-codex", - reasoning_effort: "medium", - developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, - }, - }, - }); - }); - - it("passes Codex default mode as a collaboration preset on turn/start", async () => { - const { manager, context, sendRequest } = createSendTurnHarness(); - - await manager.sendTurn({ - threadId: asThreadId("thread_1"), - input: "PLEASE IMPLEMENT THIS PLAN:\n- step 1", - interactionMode: "default", - }); - - expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { - threadId: "thread_1", - input: [ - { - type: "text", - text: "PLEASE IMPLEMENT THIS PLAN:\n- step 1", - text_elements: [], - }, - ], - model: "gpt-5.3-codex", - collaborationMode: { - mode: "default", - settings: { - model: "gpt-5.3-codex", - reasoning_effort: "medium", - developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - }, - }, - }); - }); - - it("keeps the session model when interaction mode is set without an explicit model", async () => { - const { manager, context, sendRequest } = createSendTurnHarness(); - context.session.model = "gpt-5.2-codex"; - - await manager.sendTurn({ - threadId: asThreadId("thread_1"), - input: "Plan this with my current session model", - interactionMode: "plan", - }); - - expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { - threadId: "thread_1", - input: [ - { - type: "text", - text: "Plan this with my current session model", - text_elements: [], - }, - ], - model: "gpt-5.2-codex", - collaborationMode: { - mode: "plan", - settings: { - model: "gpt-5.2-codex", - reasoning_effort: "medium", - developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, - }, - }, - }); - }); - - it("rejects empty turn input", async () => { - const { manager } = createSendTurnHarness(); - - await expect( - manager.sendTurn({ - threadId: asThreadId("thread_1"), - }), - ).rejects.toThrow("Turn input must include text or attachments."); - }); -}); - -describe("thread checkpoint control", () => { - it("reads thread turns from thread/read", async () => { - const { manager, context, requireSession, sendRequest } = createThreadControlHarness(); - sendRequest.mockResolvedValue({ - thread: { - id: "thread_1", - turns: [ - { - id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], - }, - ], - }, - }); - - const result = await manager.readThread(asThreadId("thread_1")); - - expect(requireSession).toHaveBeenCalledWith("thread_1"); - expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { - threadId: "thread_1", - includeTurns: true, - }); - expect(result).toEqual({ - threadId: "thread_1", - turns: [ - { - id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], - }, - ], - }); - }); - - it("reads thread turns from flat thread/read responses", async () => { - const { manager, context, sendRequest } = createThreadControlHarness(); - sendRequest.mockResolvedValue({ - threadId: "thread_1", - turns: [ - { - id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], - }, - ], - }); - - const result = await manager.readThread(asThreadId("thread_1")); - - expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { - threadId: "thread_1", - includeTurns: true, - }); - expect(result).toEqual({ - threadId: "thread_1", - turns: [ - { - id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], - }, - ], - }); - }); - - it("rolls back turns via thread/rollback and resets session running state", async () => { - const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); - sendRequest.mockResolvedValue({ - thread: { - id: "thread_1", - turns: [], - }, - }); - - const result = await manager.rollbackThread(asThreadId("thread_1"), 2); - - expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { - threadId: "thread_1", - numTurns: 2, - }); - expect(updateSession).toHaveBeenCalledWith(context, { - status: "ready", - activeTurnId: undefined, - }); - expect(result).toEqual({ - threadId: "thread_1", - turns: [], - }); - }); -}); - -describe("respondToUserInput", () => { - it("serializes canonical answers to Codex native answer objects", async () => { - const { manager, context, requireSession, writeMessage, emitEvent } = - createPendingUserInputHarness(); - - await manager.respondToUserInput( - asThreadId("thread_1"), - ApprovalRequestId.make("req-user-input-1"), - { - scope: "All request methods", - compat: "Keep current envelope", - }, - ); - - expect(requireSession).toHaveBeenCalledWith("thread_1"); - expect(writeMessage).toHaveBeenCalledWith(context, { - id: 42, - result: { - answers: { - scope: { answers: ["All request methods"] }, - compat: { answers: ["Keep current envelope"] }, - }, - }, - }); - expect(emitEvent).toHaveBeenCalledWith( - expect.objectContaining({ - method: "item/tool/requestUserInput/answered", - payload: { - requestId: "req-user-input-1", - answers: { - scope: { answers: ["All request methods"] }, - compat: { answers: ["Keep current envelope"] }, - }, - }, - }), - ); - }); - - it("preserves explicit empty multi-select answers", async () => { - const { manager, context, requireSession, writeMessage, emitEvent } = - createPendingUserInputHarness(); - - await manager.respondToUserInput( - asThreadId("thread_1"), - ApprovalRequestId.make("req-user-input-1"), - { - scope: [], - }, - ); - - expect(requireSession).toHaveBeenCalledWith("thread_1"); - expect(writeMessage).toHaveBeenCalledWith(context, { - id: 42, - result: { - answers: { - scope: { answers: [] }, - }, - }, - }); - expect(emitEvent).toHaveBeenCalledWith( - expect.objectContaining({ - method: "item/tool/requestUserInput/answered", - payload: { - requestId: "req-user-input-1", - answers: { - scope: { answers: [] }, - }, - }, - }), - ); - }); - - it("tracks file-read approval requests with the correct method", () => { - const manager = new CodexAppServerManager(); - const context = { - session: { - sessionId: "sess_1", - provider: "codex", - status: "ready", - threadId: asThreadId("thread_1"), - resumeCursor: { threadId: "thread_1" }, - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - collabReceiverTurns: new Map(), - }; - type ApprovalRequestContext = { - session: typeof context.session; - pendingApprovals: typeof context.pendingApprovals; - pendingUserInputs: typeof context.pendingUserInputs; - }; - - ( - manager as unknown as { - handleServerRequest: ( - context: ApprovalRequestContext, - request: Record, - ) => void; - } - ).handleServerRequest(context, { - jsonrpc: "2.0", - id: 42, - method: "item/fileRead/requestApproval", - params: {}, - }); - - const request = Array.from(context.pendingApprovals.values())[0]; - expect(request?.requestKind).toBe("file-read"); - expect(request?.method).toBe("item/fileRead/requestApproval"); - }); -}); - -describe("collab child conversation routing", () => { - it("rewrites child notification turn ids onto the parent turn", () => { - const { manager, context, emitEvent } = createCollabNotificationHarness(); - - ( - manager as unknown as { - handleServerNotification: (context: unknown, notification: Record) => void; - } - ).handleServerNotification(context, { - method: "item/completed", - params: { - item: { - type: "collabAgentToolCall", - id: "call_collab_1", - receiverThreadIds: ["child_provider_1"], - }, - threadId: "provider_parent", - turnId: "turn_parent", - }, - }); - - ( - manager as unknown as { - handleServerNotification: (context: unknown, notification: Record) => void; - } - ).handleServerNotification(context, { - method: "item/agentMessage/delta", - params: { - threadId: "child_provider_1", - turnId: "turn_child_1", - itemId: "msg_child_1", - delta: "working", - }, - }); - - expect(emitEvent).toHaveBeenLastCalledWith( - expect.objectContaining({ - method: "item/agentMessage/delta", - turnId: "turn_parent", - itemId: "msg_child_1", - }), - ); - }); - - it("suppresses child lifecycle notifications so they cannot replace the parent turn", () => { - const { manager, context, emitEvent, updateSession } = createCollabNotificationHarness(); - - ( - manager as unknown as { - handleServerNotification: (context: unknown, notification: Record) => void; - } - ).handleServerNotification(context, { - method: "item/completed", - params: { - item: { - type: "collabAgentToolCall", - id: "call_collab_1", - receiverThreadIds: ["child_provider_1"], - }, - threadId: "provider_parent", - turnId: "turn_parent", - }, - }); - emitEvent.mockClear(); - updateSession.mockClear(); - - ( - manager as unknown as { - handleServerNotification: (context: unknown, notification: Record) => void; - } - ).handleServerNotification(context, { - method: "turn/started", - params: { - threadId: "child_provider_1", - turn: { id: "turn_child_1" }, - }, - }); - - ( - manager as unknown as { - handleServerNotification: (context: unknown, notification: Record) => void; - } - ).handleServerNotification(context, { - method: "turn/completed", - params: { - threadId: "child_provider_1", - turn: { id: "turn_child_1", status: "completed" }, - }, - }); - - expect(emitEvent).not.toHaveBeenCalled(); - expect(updateSession).not.toHaveBeenCalled(); - }); - - it("rewrites child approval requests onto the parent turn", () => { - const { manager, context, emitEvent } = createCollabNotificationHarness(); - - ( - manager as unknown as { - handleServerNotification: (context: unknown, notification: Record) => void; - } - ).handleServerNotification(context, { - method: "item/completed", - params: { - item: { - type: "collabAgentToolCall", - id: "call_collab_1", - receiverThreadIds: ["child_provider_1"], - }, - threadId: "provider_parent", - turnId: "turn_parent", - }, - }); - emitEvent.mockClear(); - - ( - manager as unknown as { - handleServerRequest: (context: unknown, request: Record) => void; - } - ).handleServerRequest(context, { - id: 42, - method: "item/commandExecution/requestApproval", - params: { - threadId: "child_provider_1", - turnId: "turn_child_1", - itemId: "call_child_1", - command: "bun install", - }, - }); - - expect(Array.from(context.pendingApprovals.values())[0]).toEqual( - expect.objectContaining({ - turnId: "turn_parent", - itemId: "call_child_1", - }), - ); - expect(emitEvent).toHaveBeenCalledWith( - expect.objectContaining({ - method: "item/commandExecution/requestApproval", - turnId: "turn_parent", - itemId: "call_child_1", - }), - ); - }); -}); - -describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { - it("keeps prior thread history when resuming with a changed runtime mode", async () => { - const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-")); - writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8"); - - const manager = new CodexAppServerManager(); - - try { - const firstSession = await manager.startSession({ - threadId: asThreadId("thread-live"), - provider: "codex", - cwd: workspaceDir, - runtimeMode: "full-access", - binaryPath: process.env.CODEX_BINARY_PATH!, - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), - }); - - const firstTurn = await manager.sendTurn({ - threadId: firstSession.threadId, - input: `Reply with exactly the word ALPHA ${randomUUID()}`, - }); - - expect(firstTurn.threadId).toBe(firstSession.threadId); - - await vi.waitFor( - async () => { - const snapshot = await manager.readThread(firstSession.threadId); - expect(snapshot.turns.length).toBeGreaterThan(0); - }, - { timeout: 120_000, interval: 1_000 }, - ); - - const firstSnapshot = await manager.readThread(firstSession.threadId); - const originalThreadId = firstSnapshot.threadId; - const originalTurnCount = firstSnapshot.turns.length; - - manager.stopSession(firstSession.threadId); - - const resumedSession = await manager.startSession({ - threadId: firstSession.threadId, - provider: "codex", - cwd: workspaceDir, - runtimeMode: "approval-required", - resumeCursor: firstSession.resumeCursor, - binaryPath: process.env.CODEX_BINARY_PATH!, - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), - }); - - expect(resumedSession.threadId).toBe(originalThreadId); - - const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId); - expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); - expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); - - await manager.sendTurn({ - threadId: resumedSession.threadId, - input: `Reply with exactly the word BETA ${randomUUID()}`, - }); - - await vi.waitFor( - async () => { - const snapshot = await manager.readThread(resumedSession.threadId); - expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount); - }, - { timeout: 120_000, interval: 1_000 }, - ); - } finally { - manager.stopAll(); - rmSync(workspaceDir, { recursive: true, force: true }); - } - }, 180_000); -}); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts deleted file mode 100644 index 230ba8e3641..00000000000 --- a/apps/server/src/codexAppServerManager.ts +++ /dev/null @@ -1,1598 +0,0 @@ -import { type ChildProcessWithoutNullStreams, spawn, spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; -import readline from "node:readline"; - -import { - ApprovalRequestId, - EventId, - ProviderItemId, - ProviderRequestKind, - type ProviderUserInputAnswers, - ThreadId, - TurnId, - type ProviderApprovalDecision, - type ProviderEvent, - type ProviderSession, - type ProviderTurnStartResult, - RuntimeMode, - ProviderInteractionMode, -} from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; -import { Effect, Context } from "effect"; - -import { - formatCodexCliUpgradeMessage, - isCodexCliVersionSupported, - parseCodexCliVersion, -} from "./provider/codexCliVersion"; -import { - readCodexAccountSnapshot, - resolveCodexModelForAccount, - type CodexAccountSnapshot, -} from "./provider/codexAccount"; -import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; - -export { buildCodexInitializeParams } from "./provider/codexAppServer"; -export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; - -type PendingRequestKey = string; - -interface PendingRequest { - method: string; - timeout: ReturnType; - resolve: (value: unknown) => void; - reject: (error: Error) => void; -} - -interface PendingApprovalRequest { - requestId: ApprovalRequestId; - jsonRpcId: string | number; - method: - | "item/commandExecution/requestApproval" - | "item/fileChange/requestApproval" - | "item/fileRead/requestApproval"; - requestKind: ProviderRequestKind; - threadId: ThreadId; - turnId?: TurnId; - itemId?: ProviderItemId; -} - -interface PendingUserInputRequest { - requestId: ApprovalRequestId; - jsonRpcId: string | number; - threadId: ThreadId; - turnId?: TurnId; - itemId?: ProviderItemId; -} - -interface CodexUserInputAnswer { - answers: string[]; -} - -interface CodexSessionContext { - session: ProviderSession; - account: CodexAccountSnapshot; - child: ChildProcessWithoutNullStreams; - output: readline.Interface; - pending: Map; - pendingApprovals: Map; - pendingUserInputs: Map; - collabReceiverTurns: Map; - nextRequestId: number; - stopping: boolean; -} - -interface JsonRpcError { - code?: number; - message?: string; -} - -interface JsonRpcRequest { - id: string | number; - method: string; - params?: unknown; -} - -interface JsonRpcResponse { - id: string | number; - result?: unknown; - error?: JsonRpcError; -} - -interface JsonRpcNotification { - method: string; - params?: unknown; -} - -export interface CodexAppServerSendTurnInput { - readonly threadId: ThreadId; - readonly input?: string; - readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; - readonly model?: string; - readonly serviceTier?: string | null; - readonly effort?: string; - readonly interactionMode?: ProviderInteractionMode; -} - -export interface CodexAppServerStartSessionInput { - readonly threadId: ThreadId; - readonly provider?: "codex"; - readonly cwd?: string; - readonly model?: string; - readonly serviceTier?: string; - readonly resumeCursor?: unknown; - readonly binaryPath: string; - readonly homePath?: string; - readonly runtimeMode: RuntimeMode; -} - -export interface CodexThreadTurnSnapshot { - id: TurnId; - items: unknown[]; -} - -export interface CodexThreadSnapshot { - threadId: string; - turns: CodexThreadTurnSnapshot[]; -} - -const CODEX_VERSION_CHECK_TIMEOUT_MS = 4_000; - -const ANSI_ESCAPE_CHAR = String.fromCharCode(27); -const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); -const CODEX_STDERR_LOG_REGEX = - /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/; -const BENIGN_ERROR_LOG_SNIPPETS = [ - "state db missing rollout path for thread", - "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", -]; -const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ - "not found", - "missing thread", - "no such thread", - "unknown thread", - "does not exist", -]; -export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) - -You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. - -## Mode rules (strict) - -You are in **Plan Mode** until a developer message explicitly ends it. - -Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. - -## Plan Mode vs update_plan tool - -Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`\` block. - -Separately, \`update_plan\` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use \`update_plan\` in Plan mode, it will return an error. - -## Execution vs. mutation in Plan Mode - -You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. - -### Allowed (non-mutating, plan-improving) - -Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: - -* Reading or searching files, configs, schemas, types, manifests, and docs -* Static analysis, inspection, and repo exploration -* Dry-run style commands when they do not edit repo-tracked files -* Tests, builds, or checks that may write to caches or build artifacts (for example, \`target/\`, \`.cache/\`, or snapshots) so long as they do not edit repo-tracked files - -### Not allowed (mutating, plan-executing) - -Actions that implement the plan or change repo-tracked state. Examples: - -* Editing or writing files -* Running formatters or linters that rewrite files -* Applying patches, migrations, or codegen that updates repo-tracked files -* Side-effectful commands whose purpose is to carry out the plan rather than refine it - -When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. - -## PHASE 1 - Ground in the environment (explore first, ask second) - -Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. - -Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. - -Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. - -Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. - -## PHASE 2 - Intent chat (what they actually want) - -* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. -* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet-ask. - -## PHASE 3 - Implementation chat (what/how we'll build) - -* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. - -## Asking questions - -Critical rules: - -* Strongly prefer using the \`request_user_input\` tool to ask any questions. -* Offer only meaningful multiple-choice options; don't include filler choices that are obviously wrong or irrelevant. -* In rare cases where an unavoidable, important question can't be expressed with reasonable multiple-choice options (due to extreme ambiguity), you may ask it directly without the tool. - -You SHOULD ask many questions, but each question must: - -* materially change the spec/plan, OR -* confirm/lock an assumption, OR -* choose between meaningful tradeoffs. -* not be answerable by non-mutating commands. - -Use the \`request_user_input\` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. - -## Two kinds of unknowns (treat differently) - -1. **Discoverable facts** (repo/system truth): explore first. - - * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). - * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. - * If asking, present concrete candidates (paths/service names) + recommend one. - * Never ask questions you can answer from your environment (e.g., "where is this struct"). - -2. **Preferences/tradeoffs** (not discoverable): ask early. - - * These are intent or implementation preferences that cannot be derived from exploration. - * Provide 2-4 mutually exclusive options + a recommended default. - * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. - -## Finalization rule - -Only output the final plan when it is decision complete and leaves no decisions to the implementer. - -When you present the official plan, wrap it in a \`\` block so the client can render it specially: - -1) The opening tag must be on its own line. -2) Start the plan content on the next line (no text on the same line as the tag). -3) The closing tag must be on its own line. -4) Use Markdown inside the block. -5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. - -Example: - - -plan content - - -plan content should be human and agent digestible. The final plan must be plan-only and include: - -* A clear title -* A brief summary section -* Important changes or additions to public APIs/interfaces/types -* Test cases and scenarios -* Explicit assumptions and defaults chosen where needed - -Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. - -Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. -`; - -export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default - -You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. - -Your active mode changes only when new developer instructions with a different \`...\` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan. - -## request_user_input availability - -The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. - -In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -`; - -function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { - readonly approvalPolicy: "untrusted" | "on-request" | "never"; - readonly sandbox: "read-only" | "workspace-write" | "danger-full-access"; -} { - switch (runtimeMode) { - case "approval-required": - return { - approvalPolicy: "untrusted", - sandbox: "read-only", - }; - case "auto-accept-edits": - return { - approvalPolicy: "on-request", - sandbox: "workspace-write", - }; - case "full-access": - return { - approvalPolicy: "never", - sandbox: "danger-full-access", - }; - } -} - -/** - * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` - * wrapper, leaving the actual command running. Use `taskkill /T` to kill the - * entire process tree instead. - */ -function killChildTree(child: ChildProcessWithoutNullStreams): void { - killCodexChildProcess(child); -} - -export function normalizeCodexModelSlug( - model: string | undefined | null, - preferredId?: string, -): string | undefined { - const normalized = normalizeModelSlug(model); - if (!normalized) { - return undefined; - } - - if (preferredId?.endsWith("-codex") && preferredId !== normalized) { - return preferredId; - } - - return normalized; -} - -function buildCodexCollaborationMode(input: { - readonly interactionMode?: "default" | "plan"; - readonly model?: string; - readonly effort?: string; -}): - | { - mode: "default" | "plan"; - settings: { - model: string; - reasoning_effort: string; - developer_instructions: string; - }; - } - | undefined { - if (input.interactionMode === undefined) { - return undefined; - } - const model = normalizeCodexModelSlug(input.model) ?? "gpt-5.3-codex"; - return { - mode: input.interactionMode, - settings: { - model, - reasoning_effort: input.effort ?? "medium", - developer_instructions: - input.interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - }, - }; -} - -function toCodexUserInputAnswer(value: unknown): CodexUserInputAnswer { - if (typeof value === "string") { - return { answers: [value] }; - } - - if (Array.isArray(value)) { - const answers = value.filter((entry): entry is string => typeof entry === "string"); - return { answers }; - } - - if (value && typeof value === "object") { - const maybeAnswers = (value as { answers?: unknown }).answers; - if (Array.isArray(maybeAnswers)) { - const answers = maybeAnswers.filter((entry): entry is string => typeof entry === "string"); - return { answers }; - } - } - - throw new Error("User input answers must be strings or arrays of strings."); -} - -function toCodexUserInputAnswers( - answers: ProviderUserInputAnswers, -): Record { - return Object.fromEntries( - Object.entries(answers).map(([questionId, value]) => [ - questionId, - toCodexUserInputAnswer(value), - ]), - ); -} - -export function classifyCodexStderrLine(rawLine: string): { message: string } | null { - const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); - if (!line) { - return null; - } - - const match = line.match(CODEX_STDERR_LOG_REGEX); - if (match) { - const level = match[1]; - if (level && level !== "ERROR") { - return null; - } - - const isBenignError = BENIGN_ERROR_LOG_SNIPPETS.some((snippet) => line.includes(snippet)); - if (isBenignError) { - return null; - } - } - - return { message: line }; -} - -export function isRecoverableThreadResumeError(error: unknown): boolean { - const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); - if (!message.includes("thread/resume")) { - return false; - } - - return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); -} - -export interface CodexAppServerManagerEvents { - event: [event: ProviderEvent]; -} - -export class CodexAppServerManager extends EventEmitter { - private readonly sessions = new Map(); - - private runPromise: (effect: Effect.Effect) => Promise; - constructor(services?: Context.Context) { - super(); - this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise; - } - - async startSession(input: CodexAppServerStartSessionInput): Promise { - const threadId = input.threadId; - const now = new Date().toISOString(); - let context: CodexSessionContext | undefined; - - try { - const resolvedCwd = input.cwd ?? process.cwd(); - - const session: ProviderSession = { - provider: "codex", - status: "connecting", - runtimeMode: input.runtimeMode, - model: normalizeCodexModelSlug(input.model), - cwd: resolvedCwd, - threadId, - createdAt: now, - updatedAt: now, - }; - - const codexBinaryPath = input.binaryPath; - const codexHomePath = input.homePath; - this.assertSupportedCodexCliVersion({ - binaryPath: codexBinaryPath, - cwd: resolvedCwd, - ...(codexHomePath ? { homePath: codexHomePath } : {}), - }); - const child = spawn(codexBinaryPath, ["app-server"], { - cwd: resolvedCwd, - env: { - ...process.env, - ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}), - }, - stdio: ["pipe", "pipe", "pipe"], - shell: process.platform === "win32", - }); - const output = readline.createInterface({ input: child.stdout }); - - context = { - session, - account: { - type: "unknown", - planType: null, - sparkEnabled: true, - }, - child, - output, - pending: new Map(), - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - collabReceiverTurns: new Map(), - nextRequestId: 1, - stopping: false, - }; - - this.sessions.set(threadId, context); - this.attachProcessListeners(context); - - this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); - - await this.sendRequest(context, "initialize", buildCodexInitializeParams()); - - this.writeMessage(context, { method: "initialized" }); - try { - const modelListResponse = await this.sendRequest(context, "model/list", {}); - console.log("codex model/list response", modelListResponse); - } catch (error) { - console.log("codex model/list failed", error); - } - try { - const accountReadResponse = await this.sendRequest(context, "account/read", {}); - console.log("codex account/read response", accountReadResponse); - context.account = readCodexAccountSnapshot(accountReadResponse); - console.log("codex subscription status", { - type: context.account.type, - planType: context.account.planType, - sparkEnabled: context.account.sparkEnabled, - }); - } catch (error) { - console.log("codex account/read failed", error); - } - - const normalizedModel = resolveCodexModelForAccount( - normalizeCodexModelSlug(input.model), - context.account, - ); - const sessionOverrides = { - model: normalizedModel ?? null, - ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), - cwd: input.cwd ?? null, - ...mapCodexRuntimeMode(input.runtimeMode ?? "full-access"), - }; - - const threadStartParams = { - ...sessionOverrides, - experimentalRawEvents: false, - }; - const resumeThreadId = readResumeThreadId(input); - this.emitLifecycleEvent( - context, - "session/threadOpenRequested", - resumeThreadId - ? `Attempting to resume thread ${resumeThreadId}.` - : "Starting a new Codex thread.", - ); - await Effect.logInfo("codex app-server opening thread", { - threadId, - requestedRuntimeMode: input.runtimeMode, - requestedModel: normalizedModel ?? null, - requestedCwd: resolvedCwd, - resumeThreadId: resumeThreadId ?? null, - }).pipe(this.runPromise); - - let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; - let threadOpenResponse: unknown; - if (resumeThreadId) { - try { - threadOpenMethod = "thread/resume"; - threadOpenResponse = await this.sendRequest(context, "thread/resume", { - ...sessionOverrides, - threadId: resumeThreadId, - }); - } catch (error) { - if (!isRecoverableThreadResumeError(error)) { - this.emitErrorEvent( - context, - "session/threadResumeFailed", - error instanceof Error ? error.message : "Codex thread resume failed.", - ); - await Effect.logWarning("codex app-server thread resume failed", { - threadId, - requestedRuntimeMode: input.runtimeMode, - resumeThreadId, - recoverable: false, - cause: error instanceof Error ? error.message : String(error), - }).pipe(this.runPromise); - throw error; - } - - threadOpenMethod = "thread/start"; - this.emitLifecycleEvent( - context, - "session/threadResumeFallback", - `Could not resume thread ${resumeThreadId}; started a new thread instead.`, - ); - await Effect.logWarning("codex app-server thread resume fell back to fresh start", { - threadId, - requestedRuntimeMode: input.runtimeMode, - resumeThreadId, - recoverable: true, - cause: error instanceof Error ? error.message : String(error), - }).pipe(this.runPromise); - threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams); - } - } else { - threadOpenMethod = "thread/start"; - threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams); - } - - const threadOpenRecord = this.readObject(threadOpenResponse); - const threadIdRaw = - this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? - this.readString(threadOpenRecord, "threadId"); - if (!threadIdRaw) { - throw new Error(`${threadOpenMethod} response did not include a thread id.`); - } - const providerThreadId = threadIdRaw; - - this.updateSession(context, { - status: "ready", - resumeCursor: { threadId: providerThreadId }, - }); - this.emitLifecycleEvent( - context, - "session/threadOpenResolved", - `Codex ${threadOpenMethod} resolved.`, - ); - await Effect.logInfo("codex app-server thread open resolved", { - threadId, - threadOpenMethod, - requestedResumeThreadId: resumeThreadId ?? null, - resolvedThreadId: providerThreadId, - requestedRuntimeMode: input.runtimeMode, - }).pipe(this.runPromise); - this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${providerThreadId}`); - return { ...context.session }; - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to start Codex session."; - if (context) { - this.updateSession(context, { - status: "error", - lastError: message, - }); - this.emitErrorEvent(context, "session/startFailed", message); - this.stopSession(threadId); - } else { - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "error", - provider: "codex", - threadId, - createdAt: new Date().toISOString(), - method: "session/startFailed", - message, - }); - } - throw new Error(message, { cause: error }); - } - } - - async sendTurn(input: CodexAppServerSendTurnInput): Promise { - const context = this.requireSession(input.threadId); - context.collabReceiverTurns.clear(); - - const turnInput: Array< - { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } - > = []; - if (input.input) { - turnInput.push({ - type: "text", - text: input.input, - text_elements: [], - }); - } - for (const attachment of input.attachments ?? []) { - if (attachment.type === "image") { - turnInput.push({ - type: "image", - url: attachment.url, - }); - } - } - if (turnInput.length === 0) { - throw new Error("Turn input must include text or attachments."); - } - - const providerThreadId = readResumeThreadId({ - threadId: context.session.threadId, - runtimeMode: context.session.runtimeMode, - resumeCursor: context.session.resumeCursor, - }); - if (!providerThreadId) { - throw new Error("Session is missing provider resume thread id."); - } - const turnStartParams: { - threadId: string; - input: Array< - { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } - >; - model?: string; - serviceTier?: string | null; - effort?: string; - collaborationMode?: { - mode: "default" | "plan"; - settings: { - model: string; - reasoning_effort: string; - developer_instructions: string; - }; - }; - } = { - threadId: providerThreadId, - input: turnInput, - }; - const normalizedModel = resolveCodexModelForAccount( - normalizeCodexModelSlug(input.model ?? context.session.model), - context.account, - ); - if (normalizedModel) { - turnStartParams.model = normalizedModel; - } - if (input.serviceTier !== undefined) { - turnStartParams.serviceTier = input.serviceTier; - } - if (input.effort) { - turnStartParams.effort = input.effort; - } - const collaborationMode = buildCodexCollaborationMode({ - ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), - ...(normalizedModel !== undefined ? { model: normalizedModel } : {}), - ...(input.effort !== undefined ? { effort: input.effort } : {}), - }); - if (collaborationMode) { - if (!turnStartParams.model) { - turnStartParams.model = collaborationMode.settings.model; - } - turnStartParams.collaborationMode = collaborationMode; - } - - const response = await this.sendRequest(context, "turn/start", turnStartParams); - - const turn = this.readObject(this.readObject(response), "turn"); - const turnIdRaw = this.readString(turn, "id"); - if (!turnIdRaw) { - throw new Error("turn/start response did not include a turn id."); - } - const turnId = TurnId.make(turnIdRaw); - - this.updateSession(context, { - status: "running", - activeTurnId: turnId, - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - }); - - return { - threadId: context.session.threadId, - turnId, - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - }; - } - - async interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise { - const context = this.requireSession(threadId); - const effectiveTurnId = turnId ?? context.session.activeTurnId; - - const providerThreadId = readResumeThreadId({ - threadId: context.session.threadId, - runtimeMode: context.session.runtimeMode, - resumeCursor: context.session.resumeCursor, - }); - if (!effectiveTurnId || !providerThreadId) { - return; - } - - await this.sendRequest(context, "turn/interrupt", { - threadId: providerThreadId, - turnId: effectiveTurnId, - }); - } - - async readThread(threadId: ThreadId): Promise { - const context = this.requireSession(threadId); - const providerThreadId = readResumeThreadId({ - threadId: context.session.threadId, - runtimeMode: context.session.runtimeMode, - resumeCursor: context.session.resumeCursor, - }); - if (!providerThreadId) { - throw new Error("Session is missing a provider resume thread id."); - } - - const response = await this.sendRequest(context, "thread/read", { - threadId: providerThreadId, - includeTurns: true, - }); - return this.parseThreadSnapshot("thread/read", response); - } - - async rollbackThread(threadId: ThreadId, numTurns: number): Promise { - const context = this.requireSession(threadId); - const providerThreadId = readResumeThreadId({ - threadId: context.session.threadId, - runtimeMode: context.session.runtimeMode, - resumeCursor: context.session.resumeCursor, - }); - if (!providerThreadId) { - throw new Error("Session is missing a provider resume thread id."); - } - if (!Number.isInteger(numTurns) || numTurns < 1) { - throw new Error("numTurns must be an integer >= 1."); - } - - const response = await this.sendRequest(context, "thread/rollback", { - threadId: providerThreadId, - numTurns, - }); - this.updateSession(context, { - status: "ready", - activeTurnId: undefined, - }); - return this.parseThreadSnapshot("thread/rollback", response); - } - - async respondToRequest( - threadId: ThreadId, - requestId: ApprovalRequestId, - decision: ProviderApprovalDecision, - ): Promise { - const context = this.requireSession(threadId); - const pendingRequest = context.pendingApprovals.get(requestId); - if (!pendingRequest) { - throw new Error(`Unknown pending approval request: ${requestId}`); - } - - context.pendingApprovals.delete(requestId); - this.writeMessage(context, { - id: pendingRequest.jsonRpcId, - result: { - decision, - }, - }); - - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "notification", - provider: "codex", - threadId: context.session.threadId, - createdAt: new Date().toISOString(), - method: "item/requestApproval/decision", - turnId: pendingRequest.turnId, - itemId: pendingRequest.itemId, - requestId: pendingRequest.requestId, - requestKind: pendingRequest.requestKind, - payload: { - requestId: pendingRequest.requestId, - requestKind: pendingRequest.requestKind, - decision, - }, - }); - } - - async respondToUserInput( - threadId: ThreadId, - requestId: ApprovalRequestId, - answers: ProviderUserInputAnswers, - ): Promise { - const context = this.requireSession(threadId); - const pendingRequest = context.pendingUserInputs.get(requestId); - if (!pendingRequest) { - throw new Error(`Unknown pending user input request: ${requestId}`); - } - - context.pendingUserInputs.delete(requestId); - const codexAnswers = toCodexUserInputAnswers(answers); - this.writeMessage(context, { - id: pendingRequest.jsonRpcId, - result: { - answers: codexAnswers, - }, - }); - - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "notification", - provider: "codex", - threadId: context.session.threadId, - createdAt: new Date().toISOString(), - method: "item/tool/requestUserInput/answered", - turnId: pendingRequest.turnId, - itemId: pendingRequest.itemId, - requestId: pendingRequest.requestId, - payload: { - requestId: pendingRequest.requestId, - answers: codexAnswers, - }, - }); - } - - stopSession(threadId: ThreadId): void { - const context = this.sessions.get(threadId); - if (!context) { - return; - } - - context.stopping = true; - - for (const pending of context.pending.values()) { - clearTimeout(pending.timeout); - pending.reject(new Error("Session stopped before request completed.")); - } - context.pending.clear(); - context.pendingApprovals.clear(); - context.pendingUserInputs.clear(); - - context.output.close(); - - if (!context.child.killed) { - killChildTree(context.child); - } - - this.updateSession(context, { - status: "closed", - activeTurnId: undefined, - }); - this.emitLifecycleEvent(context, "session/closed", "Session stopped"); - this.sessions.delete(threadId); - } - - listSessions(): ProviderSession[] { - return Array.from(this.sessions.values(), ({ session }) => ({ - ...session, - })); - } - - hasSession(threadId: ThreadId): boolean { - return this.sessions.has(threadId); - } - - stopAll(): void { - for (const threadId of this.sessions.keys()) { - this.stopSession(threadId); - } - } - - private requireSession(threadId: ThreadId): CodexSessionContext { - const context = this.sessions.get(threadId); - if (!context) { - throw new Error(`Unknown session for thread: ${threadId}`); - } - - if (context.session.status === "closed") { - throw new Error(`Session is closed for thread: ${threadId}`); - } - - return context; - } - - private attachProcessListeners(context: CodexSessionContext): void { - context.output.on("line", (line) => { - this.handleStdoutLine(context, line); - }); - - context.child.stderr.on("data", (chunk: Buffer) => { - const raw = chunk.toString(); - const lines = raw.split(/\r?\n/g); - for (const rawLine of lines) { - const classified = classifyCodexStderrLine(rawLine); - if (!classified) { - continue; - } - - this.emitNotificationEvent(context, "process/stderr", classified.message); - } - }); - - context.child.on("error", (error) => { - const message = error.message || "codex app-server process errored."; - this.updateSession(context, { - status: "error", - lastError: message, - }); - this.emitErrorEvent(context, "process/error", message); - }); - - context.child.on("exit", (code, signal) => { - if (context.stopping) { - return; - } - - const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; - this.updateSession(context, { - status: "closed", - activeTurnId: undefined, - lastError: code === 0 ? context.session.lastError : message, - }); - this.emitLifecycleEvent(context, "session/exited", message); - this.sessions.delete(context.session.threadId); - }); - } - - private handleStdoutLine(context: CodexSessionContext, line: string): void { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - this.emitErrorEvent( - context, - "protocol/parseError", - "Received invalid JSON from codex app-server.", - ); - return; - } - - if (!parsed || typeof parsed !== "object") { - this.emitErrorEvent( - context, - "protocol/invalidMessage", - "Received non-object protocol message.", - ); - return; - } - - if (this.isServerRequest(parsed)) { - this.handleServerRequest(context, parsed); - return; - } - - if (this.isServerNotification(parsed)) { - this.handleServerNotification(context, parsed); - return; - } - - if (this.isResponse(parsed)) { - this.handleResponse(context, parsed); - return; - } - - this.emitErrorEvent( - context, - "protocol/unrecognizedMessage", - "Received protocol message in an unknown shape.", - ); - } - - private handleServerNotification( - context: CodexSessionContext, - notification: JsonRpcNotification, - ): void { - const rawRoute = this.readRouteFields(notification.params); - this.rememberCollabReceiverTurns(context, notification.params, rawRoute.turnId); - const childParentTurnId = this.readChildParentTurnId(context, notification.params); - const isChildConversation = childParentTurnId !== undefined; - if ( - isChildConversation && - this.shouldSuppressChildConversationNotification(notification.method) - ) { - return; - } - const textDelta = - notification.method === "item/agentMessage/delta" - ? this.readString(notification.params, "delta") - : undefined; - - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "notification", - provider: "codex", - threadId: context.session.threadId, - createdAt: new Date().toISOString(), - method: notification.method, - ...((childParentTurnId ?? rawRoute.turnId) - ? { turnId: childParentTurnId ?? rawRoute.turnId } - : {}), - ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}), - textDelta, - payload: notification.params, - }); - - if (notification.method === "thread/started") { - const providerThreadId = normalizeProviderThreadId( - this.readString(this.readObject(notification.params)?.thread, "id"), - ); - if (providerThreadId) { - this.updateSession(context, { resumeCursor: { threadId: providerThreadId } }); - } - return; - } - - if (notification.method === "turn/started") { - if (isChildConversation) { - return; - } - const turnId = toTurnId(this.readString(this.readObject(notification.params)?.turn, "id")); - this.updateSession(context, { - status: "running", - activeTurnId: turnId, - }); - return; - } - - if (notification.method === "turn/completed") { - if (isChildConversation) { - return; - } - context.collabReceiverTurns.clear(); - const turn = this.readObject(notification.params, "turn"); - const status = this.readString(turn, "status"); - const errorMessage = this.readString(this.readObject(turn, "error"), "message"); - this.updateSession(context, { - status: status === "failed" ? "error" : "ready", - activeTurnId: undefined, - lastError: errorMessage ?? context.session.lastError, - }); - return; - } - - if (notification.method === "error") { - if (isChildConversation) { - return; - } - const message = this.readString(this.readObject(notification.params)?.error, "message"); - const willRetry = this.readBoolean(notification.params, "willRetry"); - - this.updateSession(context, { - status: willRetry ? "running" : "error", - lastError: message ?? context.session.lastError, - }); - } - } - - private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { - const rawRoute = this.readRouteFields(request.params); - const childParentTurnId = this.readChildParentTurnId(context, request.params); - const effectiveTurnId = childParentTurnId ?? rawRoute.turnId; - const requestKind = this.requestKindForMethod(request.method); - let requestId: ApprovalRequestId | undefined; - if (requestKind) { - requestId = ApprovalRequestId.make(randomUUID()); - const pendingRequest: PendingApprovalRequest = { - requestId, - jsonRpcId: request.id, - method: - requestKind === "command" - ? "item/commandExecution/requestApproval" - : requestKind === "file-read" - ? "item/fileRead/requestApproval" - : "item/fileChange/requestApproval", - requestKind, - threadId: context.session.threadId, - ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}), - ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}), - }; - context.pendingApprovals.set(requestId, pendingRequest); - } - - if (request.method === "item/tool/requestUserInput") { - requestId = ApprovalRequestId.make(randomUUID()); - context.pendingUserInputs.set(requestId, { - requestId, - jsonRpcId: request.id, - threadId: context.session.threadId, - ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}), - ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}), - }); - } - - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "request", - provider: "codex", - threadId: context.session.threadId, - createdAt: new Date().toISOString(), - method: request.method, - ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}), - ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}), - requestId, - requestKind, - payload: request.params, - }); - - if (requestKind) { - return; - } - - if (request.method === "item/tool/requestUserInput") { - return; - } - - this.writeMessage(context, { - id: request.id, - error: { - code: -32601, - message: `Unsupported server request: ${request.method}`, - }, - }); - } - - private handleResponse(context: CodexSessionContext, response: JsonRpcResponse): void { - const key = String(response.id); - const pending = context.pending.get(key); - if (!pending) { - return; - } - - clearTimeout(pending.timeout); - context.pending.delete(key); - - if (response.error?.message) { - pending.reject(new Error(`${pending.method} failed: ${String(response.error.message)}`)); - return; - } - - pending.resolve(response.result); - } - - private async sendRequest( - context: CodexSessionContext, - method: string, - params: unknown, - timeoutMs = 20_000, - ): Promise { - const id = context.nextRequestId; - context.nextRequestId += 1; - - const result = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - context.pending.delete(String(id)); - reject(new Error(`Timed out waiting for ${method}.`)); - }, timeoutMs); - - context.pending.set(String(id), { - method, - timeout, - resolve, - reject, - }); - this.writeMessage(context, { - method, - id, - params, - }); - }); - - return result as TResponse; - } - - private writeMessage(context: CodexSessionContext, message: unknown): void { - const encoded = JSON.stringify(message); - if (!context.child.stdin.writable) { - throw new Error("Cannot write to codex app-server stdin."); - } - - context.child.stdin.write(`${encoded}\n`); - } - - private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "session", - provider: "codex", - threadId: context.session.threadId, - createdAt: new Date().toISOString(), - method, - message, - }); - } - - private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "error", - provider: "codex", - threadId: context.session.threadId, - createdAt: new Date().toISOString(), - method, - message, - }); - } - - private emitNotificationEvent( - context: CodexSessionContext, - method: string, - message: string, - ): void { - this.emitEvent({ - id: EventId.make(randomUUID()), - kind: "notification", - provider: "codex", - threadId: context.session.threadId, - createdAt: new Date().toISOString(), - method, - message, - }); - } - - private emitEvent(event: ProviderEvent): void { - this.emit("event", event); - } - - private assertSupportedCodexCliVersion(input: { - readonly binaryPath: string; - readonly cwd: string; - readonly homePath?: string; - }): void { - assertSupportedCodexCliVersion(input); - } - - private updateSession(context: CodexSessionContext, updates: Partial): void { - context.session = { - ...context.session, - ...updates, - updatedAt: new Date().toISOString(), - }; - } - - private requestKindForMethod(method: string): ProviderRequestKind | undefined { - if (method === "item/commandExecution/requestApproval") { - return "command"; - } - - if (method === "item/fileRead/requestApproval") { - return "file-read"; - } - - if (method === "item/fileChange/requestApproval") { - return "file-change"; - } - - return undefined; - } - - private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot { - const responseRecord = this.readObject(response); - const thread = this.readObject(responseRecord, "thread"); - const threadIdRaw = - this.readString(thread, "id") ?? this.readString(responseRecord, "threadId"); - if (!threadIdRaw) { - throw new Error(`${method} response did not include a thread id.`); - } - const turnsRaw = - this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; - const turns = turnsRaw.map((turnValue, index) => { - const turn = this.readObject(turnValue); - const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; - const turnId = TurnId.make(turnIdRaw); - const items = this.readArray(turn, "items") ?? []; - return { - id: turnId, - items, - }; - }); - - return { - threadId: threadIdRaw, - turns, - }; - } - - private isServerRequest(value: unknown): value is JsonRpcRequest { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - return ( - typeof candidate.method === "string" && - (typeof candidate.id === "string" || typeof candidate.id === "number") - ); - } - - private isServerNotification(value: unknown): value is JsonRpcNotification { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - return typeof candidate.method === "string" && !("id" in candidate); - } - - private isResponse(value: unknown): value is JsonRpcResponse { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - const hasId = typeof candidate.id === "string" || typeof candidate.id === "number"; - const hasMethod = typeof candidate.method === "string"; - return hasId && !hasMethod; - } - - private readRouteFields(params: unknown): { - turnId?: TurnId; - itemId?: ProviderItemId; - } { - const route: { - turnId?: TurnId; - itemId?: ProviderItemId; - } = {}; - - const turnId = toTurnId( - this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"), - ); - const itemId = toProviderItemId( - this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"), - ); - - if (turnId) { - route.turnId = turnId; - } - - if (itemId) { - route.itemId = itemId; - } - - return route; - } - - private readProviderConversationId(params: unknown): string | undefined { - return ( - this.readString(params, "threadId") ?? - this.readString(this.readObject(params, "thread"), "id") ?? - this.readString(params, "conversationId") - ); - } - - private readChildParentTurnId(context: CodexSessionContext, params: unknown): TurnId | undefined { - const providerConversationId = this.readProviderConversationId(params); - if (!providerConversationId) { - return undefined; - } - return context.collabReceiverTurns.get(providerConversationId); - } - - private rememberCollabReceiverTurns( - context: CodexSessionContext, - params: unknown, - parentTurnId: TurnId | undefined, - ): void { - if (!parentTurnId) { - return; - } - const payload = this.readObject(params); - const item = this.readObject(payload, "item") ?? payload; - const itemType = this.readString(item, "type") ?? this.readString(item, "kind"); - if (itemType !== "collabAgentToolCall") { - return; - } - - const receiverThreadIds = - this.readArray(item, "receiverThreadIds") - ?.map((value) => (typeof value === "string" ? value : null)) - .filter((value): value is string => value !== null) ?? []; - for (const receiverThreadId of receiverThreadIds) { - context.collabReceiverTurns.set(receiverThreadId, parentTurnId); - } - } - - private shouldSuppressChildConversationNotification(method: string): boolean { - return ( - method === "thread/started" || - method === "thread/status/changed" || - method === "thread/archived" || - method === "thread/unarchived" || - method === "thread/closed" || - method === "thread/compacted" || - method === "thread/name/updated" || - method === "thread/tokenUsage/updated" || - method === "turn/started" || - method === "turn/completed" || - method === "turn/aborted" || - method === "turn/plan/updated" || - method === "item/plan/delta" - ); - } - - private readObject(value: unknown, key?: string): Record | undefined { - const target = - key === undefined - ? value - : value && typeof value === "object" - ? (value as Record)[key] - : undefined; - - if (!target || typeof target !== "object") { - return undefined; - } - - return target as Record; - } - - private readArray(value: unknown, key?: string): unknown[] | undefined { - const target = - key === undefined - ? value - : value && typeof value === "object" - ? (value as Record)[key] - : undefined; - return Array.isArray(target) ? target : undefined; - } - - private readString(value: unknown, key: string): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - - const candidate = (value as Record)[key]; - return typeof candidate === "string" ? candidate : undefined; - } - - private readBoolean(value: unknown, key: string): boolean | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - - const candidate = (value as Record)[key]; - return typeof candidate === "boolean" ? candidate : undefined; - } -} - -function brandIfNonEmpty( - value: string | undefined, - maker: (value: string) => T, -): T | undefined { - const normalized = value?.trim(); - return normalized?.length ? maker(normalized) : undefined; -} - -function normalizeProviderThreadId(value: string | undefined): string | undefined { - return brandIfNonEmpty(value, (normalized) => normalized); -} - -function assertSupportedCodexCliVersion(input: { - readonly binaryPath: string; - readonly cwd: string; - readonly homePath?: string; -}): void { - const result = spawnSync(input.binaryPath, ["--version"], { - cwd: input.cwd, - env: { - ...process.env, - ...(input.homePath ? { CODEX_HOME: input.homePath } : {}), - }, - encoding: "utf8", - shell: process.platform === "win32", - stdio: ["ignore", "pipe", "pipe"], - timeout: CODEX_VERSION_CHECK_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - }); - - if (result.error) { - const lower = result.error.message.toLowerCase(); - if ( - lower.includes("enoent") || - lower.includes("command not found") || - lower.includes("not found") - ) { - throw new Error(`Codex CLI (${input.binaryPath}) is not installed or not executable.`); - } - throw new Error( - `Failed to execute Codex CLI version check: ${result.error.message || String(result.error)}`, - ); - } - - const stdout = result.stdout ?? ""; - const stderr = result.stderr ?? ""; - if (result.status !== 0) { - const detail = stderr.trim() || stdout.trim() || `Command exited with code ${result.status}.`; - throw new Error(`Codex CLI version check failed. ${detail}`); - } - - const parsedVersion = parseCodexCliVersion(`${stdout}\n${stderr}`); - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - throw new Error(formatCodexCliUpgradeMessage(parsedVersion)); - } -} - -function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { - if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { - return undefined; - } - const rawThreadId = (resumeCursor as Record).threadId; - return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; -} - -function readResumeThreadId(input: { - readonly resumeCursor?: unknown; - readonly threadId?: ThreadId; - readonly runtimeMode?: RuntimeMode; -}): string | undefined { - return readResumeCursorThreadId(input.resumeCursor); -} - -function toTurnId(value: string | undefined): TurnId | undefined { - return brandIfNonEmpty(value, TurnId.make); -} - -function toProviderItemId(value: string | undefined): ProviderItemId | undefined { - return brandIfNonEmpty(value, ProviderItemId.make); -} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index c2e5ecee459..b0a23cb273c 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,7 +6,13 @@ * * @module ServerConfig */ -import { Effect, FileSystem, Layer, LogLevel, Path, Schema, Context } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as LogLevel from "effect/LogLevel"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; export const DEFAULT_PORT = 3773; @@ -24,6 +30,7 @@ export interface ServerDerivedPaths { readonly dbPath: string; readonly keybindingsConfigPath: string; readonly settingsPath: string; + readonly providerStatusCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; readonly logsDir: string; @@ -64,6 +71,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; } export const deriveServerPaths = Effect.fn(function* ( @@ -76,11 +85,13 @@ export const deriveServerPaths = Effect.fn(function* ( const attachmentsDir = join(stateDir, "attachments"); const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); + const providerStatusCacheDir = join(baseDir, "caches"); return { stateDir, dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), + providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, logsDir, @@ -110,6 +121,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), + fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), ], @@ -154,6 +166,8 @@ export class ServerConfig extends Context.Service 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, + }); +} + +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([ + { + pid: 10, + ppid: 1, + pgid: 10, + status: "Ss", + cpuPercent: 0, + rssBytes: 1024 * 1024, + elapsed: "01:02.03", + command: "/usr/bin/node server.js", + }, + { + 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", + }, + ]); + }), + ); + + 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=", + }, + ], + }); + + 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]); + }), + ); + + 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]); + }), + ); + + it.effect("queries processes through the ChildProcessSpawner service", () => + 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 diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( + Effect.flatMap((pd) => pd.read), + 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="], + }, + ]); + }), + ); + + it.effect("does not allow signaling the diagnostics query process", () => + 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 layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer)); + + const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe( + Effect.flatMap((pd) => pd.signal({ pid: 4242, signal: "SIGINT" })), + Effect.provide(layer), + ); + + expect(result).toEqual({ + pid: 4242, + signal: "SIGINT", + signaled: false, + message: Option.some("Process 4242 is not a live descendant of the T3 server."), + }); + }), + ); +}); diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts new file mode 100644 index 00000000000..f56bf216513 --- /dev/null +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -0,0 +1,462 @@ +import type { + ServerProcessDiagnosticsEntry, + ServerProcessDiagnosticsResult, + ServerProcessSignal, + ServerSignalProcessResult, +} from "@t3tools/contracts"; +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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.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 interface ProcessDiagnosticsShape { + readonly read: Effect.Effect; + readonly signal: (input: { + readonly pid: number; + readonly signal: ServerProcessSignal; + }) => Effect.Effect; +} + +export class ProcessDiagnostics extends Context.Service< + ProcessDiagnostics, + ProcessDiagnosticsShape +>()("t3/diagnostics/ProcessDiagnostics") {} + +class ProcessDiagnosticsError extends Schema.TaggedErrorClass()( + "ProcessDiagnosticsError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} +const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); + +function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError { + return new ProcessDiagnosticsError({ + message, + ...(cause === undefined ? {} : { cause }), + }); +} + +function parsePositiveInt(value: string): number | null { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 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 }))); + } + + return entries; +} + +export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean { + if (row.ppid !== serverPid) return false; + + const command = row.command.trim(); + 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)) + ); +} + +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 exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +const runProcess = Effect.fn("runProcess")( + function* (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly errorMessage: string; + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(input.command, input.args, { + cwd: process.cwd(), + shell: process.platform === "win32", + }), + ); + 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 { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + } satisfies ProcessOutput; + }, + (effect, input) => + effect.pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isProcessDiagnosticsError(cause) + ? cause + : toProcessDiagnosticsError(input.errorMessage, cause), + ), + ), +); + +function readPosixProcessRows(): Effect.Effect< + ReadonlyArray, + ProcessDiagnosticsError, + ChildProcessSpawner.ChildProcessSpawner +> { + return runProcess({ + command: "ps", + args: ["-axo", POSIX_PROCESS_QUERY_COMMAND], + errorMessage: "Failed to query process diagnostics.", + }).pipe( + Effect.flatMap((result) => + result.exitCode !== 0 + ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed.")) + : 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], + errorMessage: "Failed to query process diagnostics.", + }).pipe( + Effect.flatMap((result) => + result.exitCode !== 0 + ? Effect.fail( + toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."), + ) + : Effect.succeed(parseWindowsProcessRows(result.stdout)), + ), + ); +} + +export const readProcessRows = (platform = process.platform) => + 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(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); + } + + 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( + toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`), + ); + }), + ); +} + +export const make = Effect.fn("makeProcessDiagnostics")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const read: ProcessDiagnosticsShape["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: ProcessDiagnosticsShape["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) => + toProcessDiagnosticsError( + `Failed to signal process ${input.pid} with ${input.signal}.`, + cause, + ), + }), + ), + Effect.catch((error: ProcessDiagnosticsError) => + Effect.succeed({ + pid: input.pid, + signal: input.signal, + signaled: false, + message: Option.some(error.message), + }), + ), + ); + }, + ); + + return ProcessDiagnostics.of({ read, signal }); +}); + +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 new file mode 100644 index 00000000000..11d12c012db --- /dev/null +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { + aggregateProcessResourceHistory, + collectMonitoredSamples, +} 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 = 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", + }, + { + pid: 101, + ppid: 100, + pgid: 100, + status: "S", + cpuPercent: 10, + rssBytes: 2_000, + elapsed: "00:20", + command: "codex app-server", + }, + { + pid: 102, + ppid: 101, + pgid: 100, + status: "R", + cpuPercent: 50, + rssBytes: 3_000, + elapsed: "00:05", + command: "rg needle", + }, + { + pid: 200, + ppid: 1, + pgid: 200, + status: "R", + cpuPercent: 99, + rssBytes: 9_000, + elapsed: "00:05", + command: "unrelated", + }, + ], + }); + + 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 = [ + ...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", + }, + ], + }), + ...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 = aggregateProcessResourceHistory({ + samples, + readAt: secondAt, + readAtMs: DateTime.toEpochMillis(secondAt), + windowMs: 60_000, + bucketMs: 10_000, + lastError: 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 = [ + ...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", + }, + ], + }), + ...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 = aggregateProcessResourceHistory({ + samples, + readAt: secondAt, + readAtMs: DateTime.toEpochMillis(secondAt), + windowMs: 60_000, + bucketMs: 10_000, + lastError: 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 = collectMonitoredSamples({ + serverPid: 100, + sampledAt, + sampledAtMs: DateTime.toEpochMillis(sampledAt), + rows: [ + { + pid: 100, + ppid: 1, + pgid: 100, + status: "S", + cpuPercent: 1, + rssBytes: 1_000, + elapsed: "01:00", + command: "t3 server", + }, + ...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 = aggregateProcessResourceHistory({ + samples, + readAt: sampledAt, + readAtMs: DateTime.toEpochMillis(sampledAt), + windowMs: 60_000, + bucketMs: 10_000, + lastError: null, + }); + + expect(result.topProcesses).toHaveLength(36); + expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); + }), + ); +}); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts new file mode 100644 index 00000000000..2b6dfe8d362 --- /dev/null +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -0,0 +1,299 @@ +import type { + ServerProcessResourceHistoryBucket, + ServerProcessResourceHistoryInput, + ServerProcessResourceHistoryResult, + ServerProcessResourceHistorySummary, +} 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 { ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildDescendantEntries, + isDiagnosticsQueryProcess, + type ProcessRow, + readProcessRows, +} 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; +} + +interface MonitorState { + readonly samples: ReadonlyArray; + readonly lastError: string | null; +} + +export interface ProcessResourceMonitorShape { + readonly readHistory: ( + input: ServerProcessResourceHistoryInput, + ) => Effect.Effect; +} + +export class ProcessResourceMonitor extends Context.Service< + ProcessResourceMonitor, + ProcessResourceMonitorShape +>()("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): 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) => !isDiagnosticsQueryProcess(row, input.serverPid)); + const root = findServerRootRow(rows, input.serverPid); + const descendants = 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 lastError: string | 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.lastError ? Option.some({ message: input.lastError }) : Option.none(), + }; +} + +export const make = Effect.fn("makeProcessResourceMonitor")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const state = yield* Ref.make({ samples: [], lastError: null }); + + const sampleOnce = Effect.gen(function* () { + const sampledAt = yield* DateTime.now; + const sampledAtMs = DateTime.toEpochMillis(sampledAt); + const rows = yield* 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), + lastError: null, + })); + }).pipe( + Effect.catch((error: unknown) => + Ref.update(state, (current) => ({ + ...current, + lastError: error instanceof Error ? error.message : "Failed to sample process resources.", + })), + ), + ); + + yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( + Effect.forkScoped, + ); + + const readHistory: ProcessResourceMonitorShape["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, + lastError: current.lastError, + }); + }); + + return ProcessResourceMonitor.of({ readHistory }); +}); + +export const layer = Layer.effect(ProcessResourceMonitor, make()); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts new file mode 100644 index 00000000000..d4ffa4a5fc2 --- /dev/null +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -0,0 +1,258 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +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 PlatformError from "effect/PlatformError"; + +import * as TraceDiagnostics from "./TraceDiagnostics.ts"; + +function ns(ms: number): string { + return String(BigInt(ms) * 1_000_000n); +} + +function record(input: { + readonly name: string; + readonly traceId: string; + readonly spanId: string; + readonly startMs: number; + readonly durationMs: number; + readonly exit?: { readonly _tag: "Success" | "Failure" | "Interrupted"; readonly cause?: string }; + readonly events?: ReadonlyArray; +}) { + return JSON.stringify({ + type: "effect-span", + name: input.name, + traceId: input.traceId, + spanId: input.spanId, + sampled: true, + kind: "internal", + startTimeUnixNano: ns(input.startMs), + endTimeUnixNano: ns(input.startMs + input.durationMs), + durationMs: input.durationMs, + attributes: {}, + events: input.events ?? [], + links: [], + exit: input.exit ?? { _tag: "Success" }, + }); +} + +describe("TraceDiagnostics", () => { + it.effect("aggregates failures, slow spans, log levels, and parse errors", () => + Effect.sync(() => { + const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ + traceFilePath: "/tmp/server.trace.ndjson", + readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), + slowSpanThresholdMs: 1_000, + files: [ + { + path: "/tmp/server.trace.ndjson.1", + text: [ + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + "not-json", + ].join("\n"), + }, + { + path: "/tmp/server.trace.ndjson", + text: [ + record({ + name: "orchestration.dispatch", + traceId: "trace-b", + spanId: "span-b", + startMs: 2_000, + durationMs: 1_500, + exit: { _tag: "Failure", cause: "Provider crashed" }, + events: [ + { + name: "provider failed", + timeUnixNano: ns(3_400), + attributes: { "effect.logLevel": "Error" }, + }, + ], + }), + record({ + name: "orchestration.dispatch", + traceId: "trace-c", + spanId: "span-c", + startMs: 4_000, + durationMs: 250, + exit: { _tag: "Failure", cause: "Provider crashed" }, + }), + record({ + name: "git.status", + traceId: "trace-d", + spanId: "span-d", + startMs: 5_000, + durationMs: 25, + exit: { _tag: "Interrupted", cause: "Interrupted" }, + events: [ + { + name: "status delayed", + timeUnixNano: ns(5_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ].join("\n"), + }, + ], + }); + + assert.equal(diagnostics.recordCount, 4); + assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z"); + assert.equal( + Option.match(diagnostics.firstSpanAt, { + onNone: () => null, + onSome: DateTime.formatIso, + }), + "1970-01-01T00:00:01.000Z", + ); + assert.equal( + Option.match(diagnostics.lastSpanAt, { + onNone: () => null, + onSome: DateTime.formatIso, + }), + "1970-01-01T00:00:05.025Z", + ); + assert.equal(diagnostics.parseErrorCount, 1); + assert.equal(diagnostics.failureCount, 2); + assert.equal(diagnostics.interruptionCount, 1); + assert.equal(diagnostics.slowSpanCount, 1); + assert.equal(diagnostics.logLevelCounts.Error, 1); + assert.equal(diagnostics.logLevelCounts.Warning, 1); + assert.equal(diagnostics.commonFailures[0]?.name, "orchestration.dispatch"); + assert.equal(diagnostics.commonFailures[0]?.count, 2); + assert.equal(diagnostics.latestFailures[0]?.traceId, "trace-c"); + assert.equal(diagnostics.slowestSpans[0]?.traceId, "trace-b"); + assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, "status delayed"); + assert.equal(diagnostics.topSpansByCount[0]?.name, "orchestration.dispatch"); + }), + ); + + it.effect("returns a not-found diagnostic when no files are available", () => + Effect.sync(() => { + const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ + traceFilePath: "/tmp/missing.trace.ndjson", + readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), + files: [], + }); + + assert.equal(diagnostics.recordCount, 0); + assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-not-found"); + }), + ); + + it.effect("preserves full failure causes and log messages", () => + Effect.sync(() => { + const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); + const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); + const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ + traceFilePath: "/tmp/server.trace.ndjson", + readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), + files: [ + { + path: "/tmp/server.trace.ndjson", + text: record({ + name: "VcsProcess.run", + traceId: "trace-long", + spanId: "span-long", + startMs: 1_000, + durationMs: 25, + exit: { _tag: "Failure", cause: longCause }, + events: [ + { + name: longMessage, + timeUnixNano: ns(1_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + }, + ], + }); + + assert.equal(diagnostics.latestFailures[0]?.cause, longCause); + assert.equal(diagnostics.commonFailures[0]?.cause, longCause); + assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, longMessage); + }), + ); + + it.effect("keeps loaded trace data when one rotated trace file fails to read", () => + Effect.gen(function* () { + const traceFilePath = "/tmp/server.trace.ndjson"; + const fileSystemLayer = FileSystem.layerNoop({ + readFileString: (path) => + path === `${traceFilePath}.1` + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: path, + }), + ) + : Effect.succeed( + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + ), + }); + + const diagnostics = yield* TraceDiagnostics.readTraceDiagnostics({ + traceFilePath, + maxFiles: 1, + readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), + }).pipe(Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)))); + + assert.equal(diagnostics.recordCount, 1); + assert.equal( + Option.getOrElse(diagnostics.partialFailure, () => false), + true, + ); + assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-read-failed"); + assert.deepStrictEqual(diagnostics.scannedFilePaths, [`${traceFilePath}.1`, traceFilePath]); + }), + ); + + it.effect("keeps only the slowest span occurrences while aggregating large inputs", () => + Effect.sync(() => { + const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ + traceFilePath: "/tmp/server.trace.ndjson", + readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), + files: [ + { + path: "/tmp/server.trace.ndjson", + text: Array.from({ length: 25 }, (_, index) => + record({ + name: `span-${index}`, + traceId: `trace-${index}`, + spanId: `span-${index}`, + startMs: index * 1_000, + durationMs: index, + }), + ).join("\n"), + }, + ], + }); + + assert.equal(diagnostics.recordCount, 25); + assert.equal(diagnostics.slowestSpans.length, 10); + assert.deepStrictEqual( + diagnostics.slowestSpans.map((span) => span.durationMs), + [24, 23, 22, 21, 20, 19, 18, 17, 16, 15], + ); + }), + ); +}); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts new file mode 100644 index 00000000000..ff63410b9bc --- /dev/null +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -0,0 +1,461 @@ +import type { + ServerTraceDiagnosticsErrorKind, + ServerTraceDiagnosticsFailureSummary, + ServerTraceDiagnosticsLogEvent, + ServerTraceDiagnosticsRecentFailure, + ServerTraceDiagnosticsResult, + ServerTraceDiagnosticsSpanOccurrence, + ServerTraceDiagnosticsSpanSummary, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +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 PlatformError from "effect/PlatformError"; + +interface TraceRecordLike { + readonly name?: unknown; + readonly traceId?: unknown; + readonly spanId?: unknown; + readonly startTimeUnixNano?: unknown; + readonly endTimeUnixNano?: unknown; + readonly durationMs?: unknown; + readonly exit?: unknown; + readonly events?: unknown; +} + +interface TraceEventLike { + readonly name?: unknown; + readonly timeUnixNano?: unknown; + readonly attributes?: unknown; +} + +export interface TraceDiagnosticsOptions { + readonly traceFilePath: string; + readonly maxFiles: number; + readonly slowSpanThresholdMs?: number; + readonly readAt?: DateTime.Utc; +} + +export interface TraceDiagnosticsShape { + readonly read: (options: TraceDiagnosticsOptions) => Effect.Effect; +} + +export class TraceDiagnostics extends Context.Service()( + "t3/diagnostics/TraceDiagnostics", +) {} + +interface TraceDiagnosticsInput { + readonly traceFilePath: string; + readonly files: ReadonlyArray<{ readonly path: string; readonly text: string }>; + readonly scannedFilePaths?: ReadonlyArray; + readonly slowSpanThresholdMs?: number; + readonly readAt: DateTime.Utc; + readonly error?: TraceDiagnosticsErrorSummary; + readonly partialFailure?: boolean; +} + +interface TraceDiagnosticsErrorSummary { + readonly kind: ServerTraceDiagnosticsErrorKind; + readonly message: string; +} + +const DEFAULT_SLOW_SPAN_THRESHOLD_MS = 1_000; +const TOP_LIMIT = 10; +const RECENT_LIMIT = 20; +function toRotatedTracePaths(traceFilePath: string, maxFiles: number): ReadonlyArray { + const backupCount = Math.max(0, Math.floor(maxFiles)); + const backups = Array.from( + { length: backupCount }, + (_, index) => `${traceFilePath}.${backupCount - index}`, + ); + return [...backups, traceFilePath]; +} + +function isRecordObject(value: unknown): value is TraceRecordLike { + return typeof value === "object" && value !== null; +} + +function toStringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function toNumberValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function unixNanoToDateTime(value: unknown): DateTime.Utc | null { + const text = toStringValue(value); + if (!text) return null; + try { + const millis = Number(BigInt(text) / 1_000_000n); + return Option.getOrNull(DateTime.make(millis)); + } catch { + return null; + } +} + +function readExitTag(exit: unknown): string | null { + if (!isRecordObject(exit) || !("_tag" in exit)) return null; + return toStringValue(exit._tag); +} + +function readExitCause(exit: unknown): string { + if (!isRecordObject(exit) || !("cause" in exit)) return "Failure"; + return toStringValue(exit.cause)?.trim() ?? "Failure"; +} + +function isTraceEvent(value: unknown): value is TraceEventLike { + return typeof value === "object" && value !== null; +} + +function readEventAttributes(event: TraceEventLike): Readonly> { + return typeof event.attributes === "object" && event.attributes !== null + ? (event.attributes as Readonly>) + : {}; +} + +function makeEmptyDiagnostics(input: { + readonly traceFilePath: string; + readonly scannedFilePaths: ReadonlyArray; + readonly readAt: DateTime.Utc; + readonly slowSpanThresholdMs: number; + readonly error?: TraceDiagnosticsErrorSummary; + readonly partialFailure?: boolean; +}): ServerTraceDiagnosticsResult { + return { + traceFilePath: input.traceFilePath, + scannedFilePaths: [...input.scannedFilePaths], + readAt: input.readAt, + recordCount: 0, + parseErrorCount: 0, + firstSpanAt: Option.none(), + lastSpanAt: Option.none(), + failureCount: 0, + interruptionCount: 0, + slowSpanThresholdMs: input.slowSpanThresholdMs, + slowSpanCount: 0, + logLevelCounts: {}, + topSpansByCount: [], + slowestSpans: [], + commonFailures: [], + latestFailures: [], + latestWarningAndErrorLogs: [], + partialFailure: input.partialFailure ? Option.some(true) : Option.none(), + error: Option.fromNullishOr(input.error), + }; +} + +function isNotFoundError(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "NotFound"; +} + +function platformErrorMessage(error: PlatformError.PlatformError): string { + return error.message || String(error); +} + +function insertBoundedSlowestSpan( + slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], + span: ServerTraceDiagnosticsSpanOccurrence, +): void { + if ( + slowestSpans.length >= TOP_LIMIT && + span.durationMs <= slowestSpans[slowestSpans.length - 1]!.durationMs + ) { + return; + } + + slowestSpans.push(span); + slowestSpans.sort((left, right) => right.durationMs - left.durationMs); + if (slowestSpans.length > TOP_LIMIT) { + slowestSpans.length = TOP_LIMIT; + } +} + +export function aggregateTraceDiagnostics( + input: TraceDiagnosticsInput, +): ServerTraceDiagnosticsResult { + const readAt = input.readAt; + const slowSpanThresholdMs = input.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; + const scannedFilePaths = input.scannedFilePaths ?? input.files.map((file) => file.path); + if (input.files.length === 0) { + return makeEmptyDiagnostics({ + traceFilePath: input.traceFilePath, + scannedFilePaths, + readAt, + slowSpanThresholdMs, + error: input.error ?? { + kind: "trace-file-not-found", + message: "No local trace files were found.", + }, + ...(input.partialFailure ? { partialFailure: true } : {}), + }); + } + + let parseErrorCount = 0; + let recordCount = 0; + let failureCount = 0; + let interruptionCount = 0; + let slowSpanCount = 0; + let firstSpanAt: DateTime.Utc | null = null; + let lastSpanAt: DateTime.Utc | null = null; + + const spansByName = new Map< + string, + { count: number; failureCount: number; totalDurationMs: number; maxDurationMs: number } + >(); + const failuresByKey = new Map(); + const latestFailures: ServerTraceDiagnosticsRecentFailure[] = []; + const slowestSpans: ServerTraceDiagnosticsSpanOccurrence[] = []; + const latestWarningAndErrorLogs: ServerTraceDiagnosticsLogEvent[] = []; + const logLevelCounts: Record = {}; + + for (const file of input.files) { + const lines = file.text.split(/\r?\n/); + for (const line of lines) { + if (line.trim().length === 0) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + parseErrorCount += 1; + continue; + } + + if (!isRecordObject(parsed)) { + parseErrorCount += 1; + continue; + } + + const name = toStringValue(parsed.name); + const traceId = toStringValue(parsed.traceId); + const spanId = toStringValue(parsed.spanId); + const durationMs = toNumberValue(parsed.durationMs); + const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); + const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); + + if (!name || !traceId || !spanId || durationMs === null || !endedAt) { + parseErrorCount += 1; + continue; + } + + recordCount += 1; + firstSpanAt = + startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) + ? startedAt + : firstSpanAt; + lastSpanAt = + lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; + + const exitTag = readExitTag(parsed.exit); + const isFailure = exitTag === "Failure"; + const isInterrupted = exitTag === "Interrupted"; + if (isFailure) failureCount += 1; + if (isInterrupted) interruptionCount += 1; + + const spanSummary = spansByName.get(name) ?? { + count: 0, + failureCount: 0, + totalDurationMs: 0, + maxDurationMs: 0, + }; + spanSummary.count += 1; + spanSummary.totalDurationMs += durationMs; + spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); + if (isFailure) spanSummary.failureCount += 1; + spansByName.set(name, spanSummary); + + const spanItem = { name, durationMs, endedAt, traceId, spanId }; + if (durationMs >= slowSpanThresholdMs) { + slowSpanCount += 1; + } + insertBoundedSlowestSpan(slowestSpans, spanItem); + + if (isFailure) { + const cause = readExitCause(parsed.exit); + latestFailures.push({ ...spanItem, cause }); + + const failureKey = `${name}\0${cause}`; + const existing = failuresByKey.get(failureKey); + const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); + failuresByKey.set(failureKey, { + name, + cause, + count: (existing?.count ?? 0) + 1, + lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, + traceId: isLatestFailure ? traceId : existing!.traceId, + spanId: isLatestFailure ? spanId : existing!.spanId, + }); + } + + if (Array.isArray(parsed.events)) { + for (const rawEvent of parsed.events) { + if (!isTraceEvent(rawEvent)) continue; + const attributes = readEventAttributes(rawEvent); + const level = toStringValue(attributes["effect.logLevel"]); + if (!level) continue; + + logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; + const normalizedLevel = level.toLowerCase(); + if ( + normalizedLevel !== "warning" && + normalizedLevel !== "warn" && + normalizedLevel !== "error" && + normalizedLevel !== "fatal" + ) { + continue; + } + + const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; + const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; + latestWarningAndErrorLogs.push({ + spanName: name, + level, + message, + seenAt, + traceId, + spanId, + }); + } + } + } + } + + const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] + .map(([name, span]) => ({ + name, + count: span.count, + failureCount: span.failureCount, + totalDurationMs: span.totalDurationMs, + averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, + maxDurationMs: span.maxDurationMs, + })) + .toSorted((left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs) + .slice(0, TOP_LIMIT); + + return { + traceFilePath: input.traceFilePath, + scannedFilePaths, + readAt, + recordCount, + parseErrorCount, + firstSpanAt: Option.fromNullishOr(firstSpanAt), + lastSpanAt: Option.fromNullishOr(lastSpanAt), + failureCount, + interruptionCount, + slowSpanThresholdMs, + slowSpanCount, + logLevelCounts, + topSpansByCount, + slowestSpans, + commonFailures: [...failuresByKey.values()] + .toSorted( + (left, right) => + right.count - left.count || + DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), + ) + .slice(0, TOP_LIMIT), + latestFailures: latestFailures + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt), + ) + .slice(0, RECENT_LIMIT), + latestWarningAndErrorLogs: latestWarningAndErrorLogs + .toSorted( + (left, right) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt), + ) + .slice(0, RECENT_LIMIT), + partialFailure: input.partialFailure ? Option.some(true) : Option.none(), + error: Option.fromNullishOr(input.error), + }; +} + +type TraceFileReadResult = + | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } + | { readonly _tag: "Missing"; readonly path: string } + | { readonly _tag: "Failed"; readonly path: string; readonly message: string }; + +function readTraceFile( + fileSystem: FileSystem.FileSystem, + path: string, +): Effect.Effect { + return fileSystem.readFileString(path).pipe( + Effect.map((text) => ({ _tag: "Loaded" as const, path, text })), + Effect.catch((error: PlatformError.PlatformError) => + Effect.succeed( + isNotFoundError(error) + ? { _tag: "Missing" as const, path } + : { _tag: "Failed" as const, path, message: platformErrorMessage(error) }, + ), + ), + ); +} + +export const make = Effect.fn("makeTraceDiagnostics")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + const read: TraceDiagnosticsShape["read"] = Effect.fn("TraceDiagnostics.read")( + function* (options) { + const readAt = options.readAt ?? (yield* DateTime.now); + const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; + const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); + const results = yield* Effect.all( + paths.map((path) => readTraceFile(fileSystem, path)), + { + concurrency: 1, + }, + ); + const files = results.flatMap((result) => + result._tag === "Loaded" ? [{ path: result.path, text: result.text }] : [], + ); + const readFailure = results.find((result) => result._tag === "Failed"); + const readFailureError = readFailure + ? ({ + kind: "trace-file-read-failed", + message: readFailure.message.trim() || `Failed to read ${readFailure.path}.`, + } satisfies TraceDiagnosticsErrorSummary) + : undefined; + + if (files.length === 0) { + return makeEmptyDiagnostics({ + traceFilePath: options.traceFilePath, + scannedFilePaths: paths, + readAt, + slowSpanThresholdMs, + error: + readFailureError ?? + ({ + kind: "trace-file-not-found", + message: "No local trace files were found.", + } satisfies TraceDiagnosticsErrorSummary), + }); + } + + return aggregateTraceDiagnostics({ + traceFilePath: options.traceFilePath, + files, + scannedFilePaths: paths, + readAt, + slowSpanThresholdMs, + ...(readFailureError ? { partialFailure: true, error: readFailureError } : {}), + }); + }, + ); + + return TraceDiagnostics.of({ read }); +}); + +export const layer = Layer.effect(TraceDiagnostics, make()); + +export function readTraceDiagnostics( + options: TraceDiagnosticsOptions, +): Effect.Effect { + return Effect.gen(function* () { + const diagnostics = yield* TraceDiagnostics; + return yield* diagnostics.read(options); + }); +} diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts index b899a4f9870..6904c53c847 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -1,7 +1,12 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as nodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts"; import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; @@ -30,6 +35,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { mode: "web", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 506fc45af79..6972af9b3df 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -1,9 +1,14 @@ import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Path, Random } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; import { ServerConfig } from "../../config.ts"; +import { layer as ProcessRunnerLive } from "../../processRunner.ts"; import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; -import { version } from "../../../package.json" with { type: "json" }; +import packageJson from "../../../package.json" with { type: "json" }; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { @@ -77,7 +82,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function os: platformOs(), arch: platformArch(), }, - serverVersion: version, + serverVersion: packageJson.version, capabilities: { repositoryIdentity: true, }, @@ -89,4 +94,6 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function } satisfies ServerEnvironmentShape; }); -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( + Layer.provide(ProcessRunnerLive), +); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts index 3d44713510b..1237b7022ba 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -1,19 +1,36 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import { vi } from "vitest"; -vi.mock("../../processRunner.ts", () => ({ - runProcess: vi.fn(), -})); - -import { runProcess } from "../../processRunner.ts"; +import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +const runMock = vi.fn(); -const mockedRunProcess = vi.mocked(runProcess); +const ProcessRunnerTest = Layer.succeed( + ProcessRunner, + ProcessRunner.of({ + run: (input) => runMock(input), + }), +); const NoopFileSystemLayer = FileSystem.layerNoop({}); +const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); +const LinuxMachineInfoLayer = Layer.merge( + ProcessRunnerTest, + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === "/etc/machine-info"), + readFileString: (path) => + path === "/etc/machine-info" + ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') + : Effect.succeed(""), + }), +); afterEach(() => { - mockedRunProcess.mockReset(); + runMock.mockReset(); }); describe("resolveServerEnvironmentLabel", () => { @@ -23,7 +40,7 @@ describe("resolveServerEnvironmentLabel", () => { cwdBaseName: "t3code", platform: "win32", hostname: "macbook-pro", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(TestLayer)); expect(result).toBe("macbook-pro"); }), @@ -31,25 +48,30 @@ describe("resolveServerEnvironmentLabel", () => { it.effect("prefers the macOS ComputerName", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: " Julius's MacBook Pro \n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: " Julius's MacBook Pro \n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "darwin", hostname: "macbook-pro", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(TestLayer)); expect(result).toBe("Julius's MacBook Pro"); - expect(mockedRunProcess).toHaveBeenCalledWith( - "scutil", - ["--get", "ComputerName"], - expect.objectContaining({ allowNonZeroExit: true }), + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ + command: "scutil", + args: ["--get", "ComputerName"], + timeoutBehavior: "timedOutResult", + }), ); }), ); @@ -60,44 +82,39 @@ describe("resolveServerEnvironmentLabel", () => { cwdBaseName: "t3code", platform: "linux", hostname: "buildbox", - }).pipe( - Effect.provide( - FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === "/etc/machine-info"), - readFileString: (path) => - path === "/etc/machine-info" - ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') - : Effect.succeed(""), - }), - ), - ); + }).pipe(Effect.provide(LinuxMachineInfoLayer)); expect(result).toBe("Build Agent 01"); - expect(mockedRunProcess).not.toHaveBeenCalled(); + expect(runMock).not.toHaveBeenCalled(); }), ); it.effect("falls back to hostnamectl pretty hostname on Linux", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: "CI Runner\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: "CI Runner\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "linux", hostname: "runner-01", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(TestLayer)); expect(result).toBe("CI Runner"); - expect(mockedRunProcess).toHaveBeenCalledWith( - "hostnamectl", - ["--pretty"], - expect.objectContaining({ allowNonZeroExit: true }), + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ + command: "hostnamectl", + args: ["--pretty"], + timeoutBehavior: "timedOutResult", + }), ); }), ); @@ -108,7 +125,7 @@ describe("resolveServerEnvironmentLabel", () => { cwdBaseName: "t3code", platform: "win32", hostname: "JULIUS-LAPTOP", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(TestLayer)); expect(result).toBe("JULIUS-LAPTOP"); }), @@ -116,13 +133,21 @@ describe("resolveServerEnvironmentLabel", () => { it.effect("falls back to the hostname when the friendly-label command is missing", () => Effect.gen(function* () { - mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT")); + runMock.mockReturnValueOnce( + Effect.fail( + new ProcessSpawnError({ + command: "scutil", + args: ["--get", "ComputerName"], + cause: new Error("spawn scutil ENOENT"), + }), + ), + ); const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "darwin", hostname: "macbook-pro", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(TestLayer)); expect(result).toBe("macbook-pro"); }), @@ -130,19 +155,22 @@ describe("resolveServerEnvironmentLabel", () => { it.effect("falls back to the cwd basename when the hostname is blank", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: " ", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: " ", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "linux", hostname: " ", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(TestLayer)); expect(result).toBe("t3code"); }), diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts index dc776583262..7dc6bc34a43 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -1,8 +1,10 @@ import * as OS from "node:os"; -import { Effect, FileSystem } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; -import { runProcess } from "../../processRunner.ts"; +import { ProcessRunner } from "../../processRunner.ts"; interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; @@ -51,19 +53,21 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( command: string, args: readonly string[], ) { - const result = yield* Effect.tryPromise({ - try: () => - runProcess(command, args, { - allowNonZeroExit: true, - }), - catch: () => null, - }).pipe(Effect.orElseSucceed(() => null)); - - if (!result || result.code !== 0) { + const processRunner = yield* ProcessRunner; + const result = yield* processRunner + .run({ + command, + args, + timeoutBehavior: "timedOutResult", + shell: process.platform === "win32", + }) + .pipe(Effect.option); + + if (Option.isNone(result) || result.value.code !== 0) { return null; } - return normalizeLabel(result.stdout); + return normalizeLabel(result.value.stdout); }); const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts index 48586803332..1e6dea0d05f 100644 --- a/apps/server/src/environment/Services/ServerEnvironment.ts +++ b/apps/server/src/environment/Services/ServerEnvironment.ts @@ -1,6 +1,6 @@ import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; export interface ServerEnvironmentShape { readonly getEnvironmentId: Effect.Effect; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts similarity index 85% rename from apps/server/src/git/Layers/GitManager.test.ts rename to apps/server/src/git/GitManager.test.ts index fd991273d1a..530e0488cf3 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,10 +1,16 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vitest"; import type { GitActionProgressEvent, @@ -13,24 +19,28 @@ import type { ThreadId, } from "@t3tools/contracts"; -import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; -import { type GitManagerShape } from "../Services/GitManager.ts"; +import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; +import { type GitManagerShape } from "./GitManager.ts"; import { + GitHubCliError, type GitHubCliShape, type GitHubPullRequestSummary, GitHubCli, -} from "../Services/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; -import { GitCoreLive } from "./GitCore.ts"; -import { GitCore } from "../Services/GitCore.ts"; +} from "../sourceControl/GitHubCli.ts"; +import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { makeGitManager } from "./GitManager.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import { ProjectSetupScriptRunner, + ProjectSetupScriptRunnerError, type ProjectSetupScriptRunnerInput, type ProjectSetupScriptRunnerShape, -} from "../../project/Services/ProjectSetupScriptRunner.ts"; +} from "../project/Services/ProjectSetupScriptRunner.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -53,6 +63,16 @@ interface FakeGhScenario { failWith?: GitHubCliError; } +function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + interface FakeGitTextGeneration { generateCommitMessage: (input: { cwd: string; @@ -209,18 +229,27 @@ function runGit( args: readonly string[], allowNonZeroExit = false, ): Effect.Effect< - { readonly code: number; readonly stdout: string; readonly stderr: string }, + { + readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"]; + readonly stdout: string; + readonly stderr: string; + }, GitCommandError, - GitCore + GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { - const gitCore = yield* GitCore; - return yield* gitCore.execute({ + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ operation: "GitManager.test.runGit", cwd, args, allowNonZeroExit, }); + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; }); } @@ -229,7 +258,7 @@ function initRepo( ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -245,7 +274,7 @@ function initRepo( function createBareRemote(): Effect.Effect< string, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); @@ -259,7 +288,7 @@ function configureRemote( remoteName: string, remotePath: string, fetchNamespace: string, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); yield* runGit(cwd, [ @@ -271,6 +300,18 @@ function configureRemote( }); } +function configureVisibleRemoteUrlWithLocalRewrite( + cwd: string, + remoteName: string, + visibleUrl: string, + localRemotePath: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* runGit(cwd, ["config", `remote.${remoteName}.url`, visibleUrl]); + yield* runGit(cwd, ["config", `url.${localRemotePath}.insteadOf`, visibleUrl]); + }); +} + function createTextGeneration(overrides: Partial = {}): TextGenerationShape { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => @@ -379,24 +420,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ? scenario.prListByHeadSelector?.[headSelector] : undefined; const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; - return Effect.succeed({ - stdout, - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + return Effect.succeed(fakeGhOutput(stdout)); } if (args[0] === "pr" && args[1] === "create") { - return Effect.succeed({ - stdout: + return Effect.succeed( + fakeGhOutput( (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + ), + ); } if (args[0] === "pr" && args[1] === "view") { @@ -408,8 +440,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { headRefName: "feature/pull-request", state: "open", }; - return Effect.succeed({ - stdout: + return Effect.succeed( + fakeGhOutput( JSON.stringify({ ...pullRequest, ...(pullRequest.headRepositoryNameWithOwner @@ -427,11 +459,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } : {}), }) + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + ), + ); } if (args[0] === "pr" && args[1] === "checkout") { @@ -453,13 +482,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); } } - return { - stdout: "", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; + return fakeGhOutput(""); }, catch: (error) => isGitHubCliError(error) @@ -486,26 +509,17 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }), ); } - return Effect.succeed({ - stdout: + return Effect.succeed( + fakeGhOutput( JSON.stringify({ nameWithOwner: repository, url: cloneUrls.url, sshUrl: cloneUrls.sshUrl, }) + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + ), + ); } - return Effect.succeed({ - stdout: `${scenario.defaultBranch ?? "main"}\n`, - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); } return Effect.fail( @@ -584,6 +598,13 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + createRepository: (input) => + Effect.fail( + new GitHubCliError({ + operation: "createRepository", + detail: `Unexpected repository create: ${input.repository}`, + }), + ), checkoutPullRequest: (input) => execute({ cwd: input.cwd, @@ -639,13 +660,27 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettingsService.layerTest(); - const gitCoreLayer = GitCoreLive.pipe( + const vcsDriverLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); + const sourceControlRegistryLayer = Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + GitHubSourceControlProvider.make().pipe( + Effect.map((provider) => + SourceControlProviderRegistry.SourceControlProviderRegistry.of({ + get: () => Effect.succeed(provider), + resolveHandle: () => Effect.succeed({ provider, context: null }), + resolve: () => Effect.succeed(provider), + discover: Effect.succeed([]), + }), + ), + Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), + ), + ); const managerLayer = Layer.mergeAll( - Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), Layer.succeed( ProjectSetupScriptRunner, @@ -653,9 +688,9 @@ function makeManager(input?: { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, ), - gitCoreLayer, + vcsDriverLayer, serverSettingsLayer, - ).pipe(Layer.provideMerge(NodeServices.layer)); + ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); return makeGitManager().pipe( Effect.provide(managerLayer), @@ -665,8 +700,9 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; -const GitManagerTestLayer = GitCoreLive.pipe( +const GitManagerTestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); @@ -683,6 +719,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 13, @@ -698,15 +735,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.isRepo).toBe(true); - expect(status.hasOriginRemote).toBe(true); - expect(status.isDefaultBranch).toBe(false); - expect(status.branch).toBe("feature/status-open-pr"); + expect(status.hasPrimaryRemote).toBe(true); + expect(status.isDefaultRef).toBe(false); + expect(status.refName).toBe("feature/status-open-pr"); expect(status.pr).toEqual({ number: 13, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseBranch: "main", - headBranch: "feature/status-open-pr", + baseRef: "main", + headRef: "feature/status-open-pr", state: "open", }); }), @@ -724,6 +761,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 14, @@ -743,8 +781,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { number: 14, title: "Existing PR title", url: "https://github.com/pingdotgg/codething-mvp/pull/14", - baseBranch: "main", - headBranch: "feature/status-trimmed-pr", + baseRef: "main", + headRef: "feature/status-trimmed-pr", state: "open", }); }), @@ -762,6 +800,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 0, @@ -794,8 +833,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { number: 15, title: "Valid PR title", url: "https://github.com/pingdotgg/codething-mvp/pull/15", - baseBranch: "main", - headBranch: "feature/status-valid-pr-entry", + baseRef: "main", + headRef: "feature/status-valid-pr-entry", state: "open", }); }), @@ -813,6 +852,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 16, @@ -843,8 +883,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { number: 17, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/17", - baseBranch: "main", - headBranch: "feature/status-lowercase-state", + baseRef: "main", + headRef: "feature/status-lowercase-state", state: "merged", }); }), @@ -859,9 +899,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status).toEqual({ isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], @@ -871,6 +911,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }); }), @@ -888,9 +929,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status).toEqual({ isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], @@ -900,6 +941,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, pr: null, }); }), @@ -923,6 +965,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }; const { manager, ghCalls } = yield* makeManager({ ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], }, }); @@ -949,6 +992,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 1661, @@ -972,7 +1016,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("main"); + expect(status.refName).toBe("main"); expect(status.pr).toBeNull(); }), ); @@ -992,17 +1036,21 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", "git@github.com:jasonLaster/codething-mvp.git", - ]); + forkDir, + ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 488, @@ -1026,13 +1074,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("t3code/pr-488/statemachine"); + expect(status.refName).toBe("t3code/pr-488/statemachine"); expect(status.pr).toEqual({ number: 488, title: "Rebase this PR on latest main", url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseBranch: "main", - headBranch: "statemachine", + baseRef: "main", + headRef: "statemachine", state: "open", }); expect(ghCalls).toContain( @@ -1056,17 +1104,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* runGit(repoDir, [ - "config", - "remote.origin.url", + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", "git@github.com:pingdotgg/codething-mvp.git", - ]); + originDir, + ); yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* runGit(repoDir, [ - "config", - "remote.my-org/upstream.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "my-org/upstream", + "ssh://git@github.com/pingdotgg/codething-mvp.git", + upstreamDir, + ); yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); @@ -1075,6 +1125,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off "effect-atom": JSON.stringify([ { number: 1618, @@ -1086,6 +1137,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-01T10:00:00Z", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -1097,8 +1149,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-01T10:00:00Z", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:effect-atom": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:effect-atom": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -1110,6 +1165,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-01T10:00:00Z", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -1126,13 +1182,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("upstream/effect-atom"); + expect(status.refName).toBe("upstream/effect-atom"); expect(status.pr).toEqual({ number: 1618, title: "Correct PR", url: "https://github.com/pingdotgg/t3code/pull/1618", - baseBranch: "main", - headBranch: "effect-atom", + baseRef: "main", + headRef: "effect-atom", state: "open", }); expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( @@ -1159,6 +1215,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 22, @@ -1176,18 +1233,49 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-merged-pr"); + expect(status.refName).toBe("feature/status-merged-pr"); expect(status.pr).toEqual({ number: 22, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/22", - baseBranch: "main", - headBranch: "feature/status-merged-pr", + baseRef: "main", + headRef: "feature/status-merged-pr", state: "merged", }); }), ); + it.effect("status hides merged PRs on the default branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 23, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/23", + baseRefName: "feature/status-default-branch-target", + headRefName: "main", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + it.effect("status prefers open PR when merged PR has newer updatedAt", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1197,6 +1285,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 45, @@ -1223,13 +1312,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-open-over-merged"); + expect(status.refName).toBe("feature/status-open-over-merged"); expect(status.pr).toEqual({ number: 46, title: "Open PR", url: "https://github.com/pingdotgg/codething-mvp/pull/46", - baseBranch: "main", - headBranch: "feature/status-open-over-merged", + baseRef: "main", + headRef: "feature/status-open-over-merged", state: "open", }); }), @@ -1254,7 +1343,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-no-gh"); + expect(status.refName).toBe("feature/status-no-gh"); expect(status.pr).toBeNull(); }), ); @@ -1547,6 +1636,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListSequence: [ "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 77, @@ -1632,6 +1722,41 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("pushes existing commits without committing dirty worktree changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n"); + yield* runGit(repoDir, ["add", "push-dirty.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); + fs.mkdirSync(path.join(repoDir, ".vercel")); + fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "push", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toContain("?? .vercel/"); + expect( + yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toBe("Push dirty branch"); + }), + ); + it.effect("create_pr pushes a clean branch before creating the PR when needed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1647,6 +1772,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListSequence: [ "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 303, @@ -1678,6 +1804,50 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("create_pr falls back to main when source control provider detection fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); + fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); + yield* runGit(repoDir, ["add", "provider-fallback.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 404, + title: "Provider fallback", + url: "https://github.com/pingdotgg/codething-mvp/pull/404", + baseRefName: "main", + headRefName: "feature/provider-fallback", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(404); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/provider-fallback"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1690,6 +1860,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 42, @@ -1733,16 +1904,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const forkDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", "git@github.com:octocat/codething-mvp.git", - ]); + forkDir, + ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 142, @@ -1795,17 +1969,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* runGit(repoDir, [ - "config", - "remote.origin.url", + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", "git@github.com:pingdotgg/codething-mvp.git", - ]); + originDir, + ); yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* runGit(repoDir, [ - "config", - "remote.my-org/upstream.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "my-org/upstream", + "ssh://git@github.com/pingdotgg/codething-mvp.git", + upstreamDir, + ); yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); yield* runGit(repoDir, ["checkout", "main"]); yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); @@ -1817,6 +1993,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off "effect-atom": JSON.stringify([ { number: 1618, @@ -1826,6 +2003,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "effect-atom", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -1835,8 +2013,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "upstream/effect-atom", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:effect-atom": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:effect-atom": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "pingdotgg:upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -1846,6 +2027,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "upstream/effect-atom", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "my-org/upstream:upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -1885,16 +2067,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", "git@github.com:octocat/codething-mvp.git", - ]); + forkDir, + ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off statemachine: JSON.stringify([ { number: 41, @@ -1904,6 +2089,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "statemachine", }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "octocat:statemachine": JSON.stringify([ { number: 142, @@ -1921,6 +2107,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "fork-seed:statemachine": JSON.stringify([]), }, }, @@ -1955,15 +2142,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", "git@github.com:octocat/codething-mvp.git", - ]); + forkDir, + ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off "octocat:statemachine": JSON.stringify([ { number: 142, @@ -1981,8 +2170,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "fork-seed:statemachine": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off statemachine: JSON.stringify([]), }, }, @@ -2022,6 +2214,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListSequence: [ "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 88, @@ -2067,6 +2260,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 1661, @@ -2084,6 +2278,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 188, @@ -2135,17 +2330,20 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", "git@github.com:octocat/codething-mvp.git", - ]); + forkDir, + ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { "octocat:statemachine": [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 188, @@ -2164,7 +2362,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), ], + // @effect-diagnostics-next-line preferSchemaOverJson:off "fork-seed:statemachine": [JSON.stringify([])], + // @effect-diagnostics-next-line preferSchemaOverJson:off statemachine: [JSON.stringify([])], }, }, @@ -2335,6 +2535,113 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "restores same-repository upstream tracking after local PR checkout without a remote ref", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); + fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "upstream.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-upstream", + ]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 65, + title: "Local upstream PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/65", + baseRefName: "main", + headRefName: "feature/pr-local-upstream", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + repositoryCloneUrls: { + "pingdotgg/codething-mvp": { + url: remoteDir, + sshUrl: remoteDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "65", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-upstream"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-upstream"); + }), + ); + + it.effect( + "restores same-repository upstream tracking when provider omits head repository metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); + fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "no-head-repo.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-no-head-repo", + ]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 66, + title: "Local upstream PR without repo metadata", + url: "https://github.com/pingdotgg/codething-mvp/pull/66", + baseRefName: "main", + headRefName: "feature/pr-local-no-head-repo", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "66", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-no-head-repo"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-no-head-repo"); + }), + ); + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2614,7 +2921,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["add", "existing.txt"]); yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); + const worktreePath = path.join(repoDir, "..", `pr-existing-${path.basename(repoDir)}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); const setupCalls: ProjectSetupScriptRunnerInput[] = []; @@ -2788,7 +3095,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); + const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${path.basename(repoDir)}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); @@ -2856,7 +3163,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, setupScriptRunner: { - runForThread: () => Effect.fail(new Error("terminal start failed")), + runForThread: () => + Effect.fail(new ProjectSetupScriptRunnerError({ message: "terminal start failed" })), }, }); @@ -2912,7 +3220,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); fs.writeFileSync( path.join(repoDir, ".git", "hooks", "pre-commit"), - '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n', + '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', { mode: 0o755 }, ); @@ -3033,7 +3341,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 201, @@ -3089,7 +3399,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect.objectContaining({ kind: "phase_started", phase: "pr", - label: "Creating GitHub pull request...", + label: "Creating pull request...", }), ]); }), diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/GitManager.ts similarity index 83% rename from apps/server/src/git/Layers/GitManager.ts rename to apps/server/src/git/GitManager.ts index a84427a194a..8dfb957b89d 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,54 +1,94 @@ import { randomUUID } from "node:crypto"; -import { realpathSync } from "node:fs"; -import { - Cache, - Duration, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Ref, - Result, -} from "effect"; +import * as Arr from "effect/Array"; +import * as Cache from "effect/Cache"; +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 Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Order from "effect/Order"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullRequestRefInput, + GitResolvePullRequestResult, + GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, - type GitStatusLocalResult, - type GitStatusRemoteResult, + VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + VcsStatusResult, ModelSelection, } from "@t3tools/contracts"; import { - detectGitHostingProviderFromRemoteUrl, + detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import { + getChangeRequestTerminologyForKind, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; import { GitManagerError } from "@t3tools/contracts"; -import { - GitManager, - type GitActionProgressReporter, - type GitManagerShape, - type GitRunStackedActionOptions, -} from "../Services/GitManager.ts"; -import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; -import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; -import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; +import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; -import { - decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, -} from "../githubPullRequests.ts"; +import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; +import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; +import type { ChangeRequest } from "@t3tools/contracts"; + +export interface GitActionProgressReporter { + readonly publish: (event: GitActionProgressEvent) => Effect.Effect; +} + +export interface GitRunStackedActionOptions { + readonly actionId?: string; + readonly progressReporter?: GitActionProgressReporter; +} + +export interface GitManagerShape { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; +} + +export class GitManager extends Context.Service()( + "t3/git/GitManager", +) {} const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -74,9 +114,14 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; - updatedAt: string | null; + updatedAt: Option.Option; } +const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( + Order.flip(Option.makeOrder(DateTime.Order)), + (pullRequest) => pullRequest.updatedAt, +); + interface ResolvedPullRequest { number: number; title: string; @@ -87,9 +132,9 @@ interface ResolvedPullRequest { } interface PullRequestHeadRemoteInfo { - isCrossRepository?: boolean; - headRepositoryNameWithOwner?: string | null; - headRepositoryOwnerLogin?: string | null; + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; } interface BranchHeadContext { @@ -255,7 +300,7 @@ function matchesBranchHeadContext( return true; } -function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { +function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { return { number: summary.number, title: summary.title, @@ -263,7 +308,7 @@ function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", - updatedAt: null, + updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -310,13 +355,14 @@ function withDescription(title: string, description: string | undefined) { function summarizeGitActionResult( result: Pick, + terms: ChangeRequestTerminology, ): { title: string; description?: string; } { if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; + const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; return withDescription(title, truncateText(result.pr.title)); } @@ -421,16 +467,16 @@ function toStatusPr(pr: PullRequestInfo): { number: number; title: string; url: string; - baseBranch: string; - headBranch: string; + baseRef: string; + headRef: string; state: "open" | "closed" | "merged"; } { return { number: pr.number, title: pr.title, url: pr.url, - baseBranch: pr.baseRefName, - headBranch: pr.headRefName, + baseRef: pr.baseRefName, + headRef: pr.headRefName, state: pr.state, }; } @@ -441,14 +487,6 @@ function normalizePullRequestReference(reference: string): string { return hashNumber?.[1] ?? trimmed; } -function canonicalizeExistingPath(value: string): string { - try { - return realpathSync.native(value); - } catch { - return value; - } -} - function toResolvedPullRequest(pr: { number: number; title: string; @@ -474,9 +512,9 @@ function shouldPreferSshRemote(url: string | null): boolean { } function toPullRequestHeadRemoteInfo(pr: { - isCrossRepository?: boolean; - headRepositoryNameWithOwner?: string | null; - headRepositoryOwnerLogin?: string | null; + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; }): PullRequestHeadRemoteInfo { return { ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), @@ -490,10 +528,12 @@ function toPullRequestHeadRemoteInfo(pr: { } export const makeGitManager = Effect.fn("makeGitManager")(function* () { - const gitCore = yield* GitCore; - const gitHubCli = yield* GitHubCli; + const gitCore = yield* GitVcsDriver; + const sourceControlProviders = yield* SourceControlProviderRegistry; const textGeneration = yield* TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + + const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( @@ -526,11 +566,27 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { localBranch = pullRequest.headBranch, ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + yield* gitCore.fetchRemoteTrackingBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + return; + } + if (repositoryNameWithOwner.length === 0) { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -546,6 +602,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { url: remoteUrl, }); + yield* gitCore.fetchRemoteTrackingBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + }); yield* gitCore.setBranchUpstream({ cwd, branch: localBranch, @@ -585,7 +646,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -634,7 +695,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const path = yield* Path.Path; const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; - const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); + const canonicalizeExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); + const normalizeStatusCacheKey = canonicalizeExistingPath; const nonRepositoryStatusDetails = { isRepo: false, hasOriginRemote: false, @@ -646,6 +709,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, } satisfies GitStatusDetails; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore @@ -659,20 +723,22 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return { isRepo: details.isRepo, - ...(hostingProvider ? { hostingProvider } : {}), - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, + ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, - } satisfies GitStatusLocalResult; + } satisfies VcsStatusLocalResult; }); const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateLocalStatusResultCache = (cwd: string) => - Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); + normalizeStatusCacheKey(cwd).pipe( + Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), + ); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { const details = yield* gitCore .statusDetails(cwd) @@ -687,7 +753,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { branch: details.branch, upstreamRef: details.upstreamRef, }).pipe( - Effect.map((latest) => (latest ? toStatusPr(latest) : null)), + Effect.map((latest) => { + if (!latest) return null; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") return null; + return toStatusPr(latest); + }), Effect.catch(() => Effect.succeed(null)), ) : null; @@ -696,15 +768,18 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, + aheadOfDefaultCount: details.aheadOfDefaultCount, pr, - } satisfies GitStatusRemoteResult; + } satisfies VcsStatusRemoteResult; }); const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateRemoteStatusResultCache = (cwd: string) => - Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); + normalizeStatusCacheKey(cwd).pipe( + Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)), + ); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); @@ -721,7 +796,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); - return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; + return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; }); const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( @@ -826,9 +901,10 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { >, ) { for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* gitHubCli.listOpenPullRequests({ + const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ cwd, headSelector, + state: "open", limit: 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -844,7 +920,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { baseRefName: firstPullRequest.baseRefName, headRefName: firstPullRequest.headRefName, state: "open", - updatedAt: null, + updatedAt: Option.none(), } satisfies PullRequestInfo; } } @@ -860,46 +936,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { - const stdout = yield* gitHubCli - .execute({ - cwd, - args: [ - "pr", - "list", - "--head", - headSelector, - "--state", - "all", - "--limit", - "20", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }) - .pipe(Effect.map((result) => result.stdout)); - - const raw = stdout.trim(); - if (raw.length === 0) { - continue; - } - - const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - gitManagerError( - "findLatestPr", - `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, - decoded.failure, - ), - ); - } - - return Effect.succeed(decoded.success); - }), - ); + const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + cwd, + headSelector, + state: "all", + limit: 20, + }); - for (const pr of pullRequests) { + for (const pr of pullRequests.map(toPullRequestInfo)) { if (!matchesBranchHeadContext(pr, headContext)) { continue; } @@ -907,11 +951,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } } - const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => { - const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; - const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; - return right - left; - }); + const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); const latestOpenPr = parsed.find((pr) => pr.state === "open"); if (latestOpenPr) { @@ -924,7 +964,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { cwd: string, result: Pick, ) { - const summary = summarizeGitActionResult(result); + const terms = yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + ); + const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; let currentBranchIsDefault = false; let finalBranchContext: { @@ -989,7 +1033,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { result.pr.status === "opened_existing") ? { kind: "open_pr" as const, - label: "View PR", + label: `View ${terms.shortLabel}`, url: openPr.url, } : (result.action === "push" || result.action === "commit_push") && @@ -997,7 +1041,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { !currentBranchIsDefault ? { kind: "run_action" as const, - label: "Create PR", + label: `Create ${terms.shortLabel}`, action: { kind: "create_pr" as const }, } : { @@ -1028,11 +1072,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } } - const defaultFromGh = yield* gitHubCli - .getDefaultBranch({ cwd }) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (defaultFromGh) { - return defaultFromGh; + const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( + Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), + Effect.catch(() => Effect.succeed(null)), + ); + if (defaultFromProvider) { + return defaultFromProvider; } return "main"; @@ -1204,6 +1249,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { fallbackBranch: string | null, emit: GitActionProgressEmitter, ) { + const provider = yield* sourceControlProvider(cwd); + const terms = getChangeRequestTerminologyForKind(provider.kind); const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -1240,7 +1287,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { yield* emit({ kind: "phase_started", phase: "pr", - label: "Generating PR content...", + label: `Generating ${terms.shortLabel} content...`, }); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); @@ -1265,12 +1312,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { yield* emit({ kind: "phase_started", phase: "pr", - label: "Creating GitHub pull request...", + label: `Creating ${terms.singular}...`, }); - yield* gitHubCli - .createPullRequest({ + yield* provider + .createChangeRequest({ cwd, - baseBranch, + baseRefName: baseBranch, headSelector: headContext.preferredHeadSelector, title: generated.title, bodyFile, @@ -1298,11 +1345,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { - return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(localStatusResultCache, cacheKey); }); const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( function* (input) { - return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { @@ -1328,8 +1377,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { - const pullRequest = yield* gitHubCli - .getPullRequest({ + const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + .getChangeRequest({ cwd: input.cwd, reference: normalizePullRequestReference(input.reference), }) @@ -1362,15 +1411,15 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ + const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, }); const pullRequest = toResolvedPullRequest(pullRequestSummary); if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ + yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ cwd: input.cwd, reference: normalizedReference, force: true, @@ -1412,33 +1461,35 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.branches.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.branches.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), + const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { + const result = yield* gitCore.listRefs({ cwd }); + const localBranch = result.refs.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + + for (const branch of result.refs) { + if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) { + continue; + } + + const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); + if (worktreePath !== rootWorktreePath) { + return branch; + } + } + + return null; + }); const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) : null; if ( existingBranchBeforeFetch?.worktreePath && @@ -1466,7 +1517,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) : null; if ( existingBranchAfterFetch?.worktreePath && @@ -1488,7 +1539,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const worktree = yield* gitCore.createWorktree({ cwd: input.cwd, - branch: localPullRequestBranch, + refName: localPullRequestBranch, path: null, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); @@ -1496,7 +1547,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return { pullRequest, - branch: worktree.worktree.branch, + branch: worktree.worktree.refName, worktreePath: worktree.worktree.path, }; }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); @@ -1528,8 +1579,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); - yield* gitCore.createBranch({ cwd, branch: resolvedBranch }); - yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch })); + yield* gitCore.createRef({ cwd, refName: resolvedBranch }); + yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); return { branchStep: { status: "created" as const, name: resolvedBranch }, @@ -1563,12 +1614,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { "Feature-branch checkout is only supported for commit actions.", ); } - if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit or stash local changes before pushing.", - ); - } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { return yield* gitManagerError( "runStackedAction", @@ -1632,6 +1677,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; + const changeRequestTerms = wantsPr + ? yield* sourceControlProvider(input.cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + ) + : null; const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( @@ -1669,7 +1720,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { .emit({ kind: "phase_started", phase: "pr", - label: "Preparing PR...", + label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`, }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), @@ -1730,4 +1781,4 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } satisfies GitManagerShape; }); -export const GitManagerLive = Layer.effect(GitManager, makeGitManager()); +export const layer = Layer.effect(GitManager, makeGitManager()); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts new file mode 100644 index 00000000000..9a34680496f --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -0,0 +1,133 @@ +import { assert, describe, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as GitManager from "./GitManager.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; + +function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistryShape["detect"] }) { + return GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: input.detect, + }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitManager.GitManager)({})), + ); +} + +describe("GitWorkflowService", () => { + it.effect("returns an empty local status when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect("returns an empty full status when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const status = yield* workflow.status({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect("does not call GitManager status methods when no VCS repository is detected", () => { + const localStatus = vi.fn(); + const remoteStatus = vi.fn(); + const status = vi.fn(); + + const testLayer = GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + detect: () => Effect.succeed(null), + }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(GitManager.GitManager)({ + localStatus, + remoteStatus, + status, + }), + ), + ); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + yield* workflow.localStatus({ cwd: "/not-a-repo" }); + yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); + yield* workflow.status({ cwd: "/not-a-repo" }); + + assert.equal(localStatus.mock.calls.length, 0); + assert.equal(remoteStatus.mock.calls.length, 0); + assert.equal(status.mock.calls.length, 0); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("returns an empty ref list when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(refs, { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); +}); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts new file mode 100644 index 00000000000..74064450fcb --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.ts @@ -0,0 +1,316 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + GitManagerError, + GitCommandError, + type VcsSwitchRefInput, + type VcsSwitchRefResult, + type VcsCreateRefInput, + type VcsCreateRefResult, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsListRefsInput, + type VcsListRefsResult, + type GitManagerServiceError, + type GitPreparePullRequestThreadInput, + type GitPreparePullRequestThreadResult, + type GitPullRequestRefInput, + type VcsPullResult, + type VcsRemoveWorktreeInput, + type GitResolvePullRequestResult, + type GitRunStackedActionInput, + type GitRunStackedActionResult, + type VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + type VcsStatusResult, +} from "@t3tools/contracts"; + +import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +export interface GitWorkflowServiceShape { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; +} + +export class GitWorkflowService extends Context.Service< + GitWorkflowService, + GitWorkflowServiceShape +>()("t3/git/GitWorkflowService") {} + +const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => + new GitManagerError({ + operation, + detail: `${detail} (${cwd})`, + }); + +const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => + new GitCommandError({ + operation, + command: "vcs-route", + cwd, + detail, + }); + +function nonRepositoryLocalStatus(): VcsStatusLocalResult { + return { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }; +} + +function nonRepositoryStatus(): VcsStatusResult { + return { + ...nonRepositoryLocalStatus(), + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }; +} + +function nonRepositoryListRefs(): VcsListRefsResult { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; +} + +export const make = Effect.fn("makeGitWorkflowService")(function* () { + const registry = yield* VcsDriverRegistry; + const git = yield* GitVcsDriver; + const gitManager = yield* GitManager; + + const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry + .resolve({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (handle.kind !== "git") { + return yield* unsupportedGitWorkflow( + operation, + cwd, + `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + }); + + const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry + .resolve({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitCommand( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (handle.kind !== "git") { + return yield* unsupportedGitCommand( + operation, + cwd, + `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + }); + + const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( + function* (operation: string, cwd: string) { + const handle = yield* registry + .detect({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (!handle) { + return false; + } + if (handle.kind !== "git") { + return yield* unsupportedGitWorkflow( + operation, + cwd, + `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + return true; + }, + ); + + const detectGitRepositoryForCommand = Effect.fn( + "GitWorkflowService.detectGitRepositoryForCommand", + )(function* (operation: string, cwd: string) { + const handle = yield* registry + .detect({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitCommand( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (!handle) { + return false; + } + if (handle.kind !== "git") { + return yield* unsupportedGitCommand( + operation, + cwd, + `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + return true; + }); + + const routeGitManager = + ( + operation: string, + run: (input: Input) => Effect.Effect, + ) => + (input: Input) => + ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); + + return GitWorkflowService.of({ + status: (input) => + detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), + ), + ), + localStatus: (input) => + detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? gitManager.localStatus(input) + : Effect.succeed(nonRepositoryLocalStatus()), + ), + ), + remoteStatus: (input) => + detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), + ), + ), + invalidateLocalStatus: gitManager.invalidateLocalStatus, + invalidateRemoteStatus: gitManager.invalidateRemoteStatus, + invalidateStatus: gitManager.invalidateStatus, + pullCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( + Effect.andThen(git.pullCurrentBranch(cwd)), + ), + runStackedAction: (input, options) => + ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( + Effect.andThen(gitManager.runStackedAction(input, options)), + ), + resolvePullRequest: routeGitManager( + "GitWorkflowService.resolvePullRequest", + gitManager.resolvePullRequest, + ), + preparePullRequestThread: routeGitManager( + "GitWorkflowService.preparePullRequestThread", + gitManager.preparePullRequestThread, + ), + listRefs: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), + ), + ), + createWorktree: (input) => + ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + Effect.andThen(git.createWorktree(input)), + ), + removeWorktree: (input) => + ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( + Effect.andThen(git.removeWorktree(input)), + ), + createRef: (input) => + ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( + Effect.andThen(git.createRef(input)), + ), + switchRef: (input) => + ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( + Effect.andThen(Effect.scoped(git.switchRef(input))), + ), + renameBranch: (input) => + ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( + Effect.andThen(git.renameBranch(input)), + ), + }); +}); + +export const layer = Layer.effect(GitWorkflowService, make()); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts deleted file mode 100644 index 08471346989..00000000000 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; -import { expect } from "vitest"; - -import { ServerConfig } from "../../config.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { sanitizeThreadTitle } from "../Utils.ts"; -import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; - -const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-claude-text-generation-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), -); - -function makeFakeClaudeBinary(dir: string) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = path.join(dir, "bin"); - const claudePath = path.join(binDir, "claude"); - yield* fs.makeDirectory(binDir, { recursive: true }); - - yield* fs.writeFileString( - claudePath, - [ - "#!/bin/sh", - 'args="$*"', - 'stdin_content="$(cat)"', - 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', - ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "args missing expected content" >&2', - " exit 2", - " }", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', - ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', - ' printf "%s\\n" "args contained forbidden content" >&2', - " exit 3", - " fi", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', - ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "stdin missing expected content" >&2', - " exit 4", - " }", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', - ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', - "fi", - 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', - 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', - "", - ].join("\n"), - ); - yield* fs.chmod(claudePath, 0o755); - return binDir; - }); -} - -function withFakeClaudeEnv( - input: { - output: string; - exitCode?: number; - stderr?: string; - argsMustContain?: string; - argsMustNotContain?: string; - stdinMustContain?: string; - }, - effect: Effect.Effect, -) { - return Effect.acquireUseRelease( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); - const binDir = yield* makeFakeClaudeBinary(tempDir); - const previousPath = process.env.PATH; - const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; - const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; - const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; - const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - - yield* Effect.sync(() => { - process.env.PATH = `${binDir}:${previousPath ?? ""}`; - process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; - - if (input.exitCode !== undefined) { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); - } else { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } - - if (input.stderr !== undefined) { - process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; - } else { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } - - if (input.argsMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } - - if (input.argsMustNotContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } - - if (input.stdinMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } - }); - - return { - previousPath, - previousOutput, - previousExitCode, - previousStderr, - previousArgsMustContain, - previousArgsMustNotContain, - previousStdinMustContain, - }; - }), - () => effect, - (previous) => - Effect.sync(() => { - process.env.PATH = previous.previousPath; - - if (previous.previousOutput === undefined) { - delete process.env.T3_FAKE_CLAUDE_OUTPUT; - } else { - process.env.T3_FAKE_CLAUDE_OUTPUT = previous.previousOutput; - } - - if (previous.previousExitCode === undefined) { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } else { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = previous.previousExitCode; - } - - if (previous.previousStderr === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } else { - process.env.T3_FAKE_CLAUDE_STDERR = previous.previousStderr; - } - - if (previous.previousArgsMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previous.previousArgsMustContain; - } - - if (previous.previousArgsMustNotContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previous.previousArgsMustNotContain; - } - - if (previous.previousStdinMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; - } - }), - ); -} - -it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { - it.effect("forwards Claude thinking settings for Haiku without passing effort", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - subject: "Add important change", - body: "", - }, - }), - argsMustContain: '--settings {"alwaysThinkingEnabled":false}', - argsMustNotContain: "--effort", - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/claude-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - effort: "high", - }, - }, - }); - - expect(generated.subject).toBe("Add important change"); - }), - ), - ); - - it.effect("forwards Claude fast mode and supported effort", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: "Improve orchestration flow", - body: "Body", - }, - }), - argsMustContain: '--effort max --settings {"fastMode":true}', - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/claude-effect", - commitSummary: "Improve orchestration", - diffSummary: "1 file changed", - diffPatch: "diff --git a/README.md b/README.md", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, - }, - }); - - expect(generated.title).toBe("Improve orchestration flow"); - }), - ), - ); - - it.effect("generates thread titles through the Claude provider", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: - ' "Reconnect failures after restart because the session state does not recover" ', - }, - }), - stdinMustContain: "You write concise thread titles for coding conversations.", - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate reconnect failures after restarting the session.", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - expect(generated.title).toBe( - sanitizeThreadTitle( - '"Reconnect failures after restart because the session state does not recover"', - ), - ); - }), - ), - ); - - it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: ' """ """ ', - }, - }), - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - }, - }); - - expect(generated.title).toBe("New thread"); - }), - ), - ); -}); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts deleted file mode 100644 index 665c4b138f9..00000000000 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ /dev/null @@ -1,2333 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; -import { describe, expect, vi } from "vitest"; - -import { GitCoreLive, makeGitCore } from "./GitCore.ts"; -import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; -import { GitCommandError } from "@t3tools/contracts"; -import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; -import { ServerConfig } from "../../config.ts"; - -// ── Helpers ── - -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); -const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), -); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); - -function makeTmpDir( - prefix = "git-test-", -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ prefix }); - }); -} - -function writeTextFile( - filePath: string, - contents: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.writeFileString(filePath, contents); - }); -} - -function removePath( - targetPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.remove(targetPath, { recursive: true, force: true }); - }); -} - -function makeDirectory( - dirPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(dirPath, { recursive: true }); - }); -} - -/** Run a raw git command for test setup (not under test). */ -function git( - cwd: string, - args: ReadonlyArray, - env?: NodeJS.ProcessEnv, -): Effect.Effect { - return Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ - operation: "GitCore.test.git", - cwd, - args, - ...(env ? { env } : {}), - timeoutMs: 10_000, - }); - return result.stdout.trim(); - }); -} - -function configureRemote( - cwd: string, - remoteName: string, - remotePath: string, - fetchNamespace: string, -): Effect.Effect { - return Effect.gen(function* () { - yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); - return yield* git(cwd, [ - "config", - "--replace-all", - `remote.${remoteName}.fetch`, - `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, - ]); - }); -} - -function runShellCommand(input: { - command: string; - cwd: string; - timeoutMs?: number; - maxOutputBytes?: number; -}): Effect.Effect { - return Effect.promise(() => { - const shellPath = - process.platform === "win32" - ? (process.env.ComSpec ?? "cmd.exe") - : (process.env.SHELL ?? "/bin/sh"); - - const args = - process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; - - return runProcess(shellPath, args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? 30_000, - allowNonZeroExit: true, - maxBufferBytes: input.maxOutputBytes ?? 1_000_000, - outputMode: "truncate", - }); - }); -} - -const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => - makeGitCore({ executeOverride }).pipe( - Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), - ); - -/** Create a repo with an initial commit so branches work. */ -function initRepoWithCommit( - cwd: string, -): Effect.Effect< - { initialBranch: string }, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem -> { - return Effect.gen(function* () { - const core = yield* GitCore; - yield* core.initRepo({ cwd }); - yield* git(cwd, ["config", "user.email", "test@test.com"]); - yield* git(cwd, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "initial commit"]); - const initialBranch = yield* git(cwd, ["branch", "--show-current"]); - return { initialBranch }; - }); -} - -function commitWithDate( - cwd: string, - fileName: string, - fileContents: string, - dateIsoString: string, - message: string, -): Effect.Effect< - void, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem -> { - return Effect.gen(function* () { - yield* writeTextFile(path.join(cwd, fileName), fileContents); - yield* git(cwd, ["add", fileName]); - yield* git(cwd, ["commit", "-m", message], { - ...process.env, - GIT_AUTHOR_DATE: dateIsoString, - GIT_COMMITTER_DATE: dateIsoString, - }); - }); -} - -function buildLargeText(lineCount = 20_000): string { - return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) - .join("\n") - .concat("\n"); -} - -function splitNullSeparatedPaths(input: string): string[] { - return input - .split("\0") - .map((value) => value.trim()) - .filter((value) => value.length > 0); -} - -// ── Tests ── - -it.layer(TestLayer)("git integration", (it) => { - describe("shell process execution", () => { - it.effect("caps captured output when maxOutputBytes is exceeded", () => - Effect.gen(function* () { - const result = yield* runShellCommand({ - command: `node -e "process.stdout.write('x'.repeat(2000))"`, - cwd: process.cwd(), - timeoutMs: 10_000, - maxOutputBytes: 128, - }); - - expect(result.code).toBe(0); - expect(result.stdout.length).toBeLessThanOrEqual(128); - expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); - }), - ); - }); - - // ── initGitRepo ── - - describe("initGitRepo", () => { - it.effect("creates a valid git repo", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* (yield* GitCore).initRepo({ cwd: tmp }); - expect(existsSync(path.join(tmp, ".git"))).toBe(true); - }), - ); - - it.effect("listGitBranches reports isRepo: true after init + commit", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.isRepo).toBe(true); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches.length).toBeGreaterThanOrEqual(1); - }), - ); - }); - - describe("workspace helpers", () => { - it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => - Effect.gen(function* () { - const cwd = "/virtual/repo"; - const relativePaths = Array.from({ length: 340 }, (_, index) => { - const prefix = index % 3 === 0 ? "ignored" : "kept"; - return `${prefix}/segment-${String(index).padStart(4, "0")}/${"x".repeat(900)}.ts`; - }); - const expectedPaths = relativePaths.filter( - (relativePath) => !relativePath.startsWith("ignored/"), - ); - - const seenChunks: string[][] = []; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args.join(" ") !== - "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin" - ) { - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "unexpected git command in chunking test", - }), - ); - } - - const chunkPaths = splitNullSeparatedPaths(input.stdin ?? ""); - seenChunks.push(chunkPaths); - const ignoredPaths = chunkPaths.filter((relativePath) => - relativePath.startsWith("ignored/"), - ); - - return Effect.succeed({ - code: ignoredPaths.length > 0 ? 0 : 1, - stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }); - - const result = yield* core.filterIgnoredPaths(cwd, relativePaths); - - expect(seenChunks.length).toBeGreaterThan(1); - expect(seenChunks.flat()).toEqual(relativePaths); - expect(result).toEqual(expectedPaths); - }), - ); - - it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => - Effect.gen(function* () { - const core = yield* makeIsolatedGitCore((input) => { - expect(input.args).toEqual([ - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ]); - return Effect.succeed({ - code: 0, - stdout: "src/index.ts\0README.md\0", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }); - - const result = yield* core.listWorkspaceFiles("/virtual/repo"); - expect(result.paths).toEqual(["src/index.ts", "README.md"]); - expect(result.truncated).toBe(false); - }), - ); - }); - - // ── listGitBranches ── - - describe("listGitBranches", () => { - it.effect("returns isRepo: false for non-git directory", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.isRepo).toBe(false); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches).toEqual([]); - }), - ); - - it.effect("returns isRepo: false for deleted directories", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const deletedDir = path.join(tmp, "deleted-repo"); - yield* makeDirectory(deletedDir); - yield* removePath(deletedDir); - - const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); - - expect(result.isRepo).toBe(false); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches).toEqual([]); - }), - ); - - it.effect("returns the current branch with current: true", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current).toBeDefined(); - expect(current!.current).toBe(true); - }), - ); - - it.effect("does not include detached HEAD pseudo-refs as branches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(tmp, ["checkout", "--detach", "HEAD"]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); - expect(result.branches.some((branch) => branch.current)).toBe(false); - }), - ); - - it.effect("keeps current branch first and sorts the remaining branches by recency", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - yield* commitWithDate( - tmp, - "older.txt", - "older branch change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "older branch change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); - yield* commitWithDate( - tmp, - "newer.txt", - "newer branch change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer branch change", - ); - - // Switch away to show current branch is pinned, then remaining branches are recency-sorted. - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches[0]!.name).toBe("older-branch"); - expect(result.branches[1]!.name).toBe("newer-branch"); - }), - ); - - it.effect("keeps default branch right after current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - yield* commitWithDate( - tmp, - "current.txt", - "current change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "current change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); - yield* commitWithDate( - tmp, - "newer.txt", - "newer change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches[0]!.name).toBe("current-branch"); - expect(result.branches[1]!.name).toBe(defaultBranch); - expect(result.branches[2]!.name).toBe("newer-branch"); - }), - ); - - it.effect("lists multiple branches after creating them", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const names = result.branches.map((b) => b.name); - expect(names).toContain("feature-a"); - expect(names).toContain("feature-b"); - }), - ); - - it.effect("paginates branch results and returns paging metadata", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" }); - - const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); - expect(firstPage.totalCount).toBe(4); - expect(firstPage.nextCursor).toBe(2); - expect(firstPage.branches.map((branch) => branch.name)).toEqual([ - initialBranch, - "feature-a", - ]); - - const secondPage = yield* (yield* GitCore).listBranches({ - cwd: tmp, - cursor: firstPage.nextCursor ?? 0, - limit: 2, - }); - expect(secondPage.totalCount).toBe(4); - expect(secondPage.nextCursor).toBeNull(); - expect(secondPage.branches.map((branch) => branch.name)).toEqual([ - "feature-b", - "feature-c", - ]); - }), - ); - - it.effect("parses separate branch names when column.ui is always enabled", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const createdBranchNames = [ - "go-bin", - "copilot/rewrite-cli-in-go", - "copilot/rewrite-cli-in-rust", - ] as const; - for (const branchName of createdBranchNames) { - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: branchName }); - } - yield* git(tmp, ["config", "column.ui", "always"]); - - const rawBranchOutput = yield* git(tmp, ["branch", "--no-color"], { - ...process.env, - COLUMNS: "120", - }); - expect( - rawBranchOutput - .split("\n") - .some( - (line) => - createdBranchNames.filter((branchName) => line.includes(branchName)).length >= 2, - ), - ).toBe(true); - - const realGitCore = yield* GitCore; - const core = yield* makeIsolatedGitCore((input) => - realGitCore.execute( - input.args[0] === "branch" - ? { - ...input, - env: { ...input.env, COLUMNS: "120" }, - } - : input, - ), - ); - - const result = yield* core.listBranches({ cwd: tmp }); - const localBranchNames = result.branches - .filter((branch) => !branch.isRemote) - .map((branch) => branch.name); - - expect(localBranchNames).toHaveLength(4); - expect(localBranchNames).toEqual( - expect.arrayContaining([initialBranch, ...createdBranchNames]), - ); - expect( - localBranchNames.some( - (branchName) => - createdBranchNames.filter((createdBranch) => branchName.includes(createdBranch)) - .length >= 2, - ), - ).toBe(false); - }), - ); - - it.effect("isDefault is false when no remote exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.every((b) => b.isDefault === false)).toBe(true); - }), - ); - - it.effect("lists local branches first and remote branches last", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - - yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" }); - - const remoteOnlyBranch = "feature/remote-only"; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); - yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); - yield* git(tmp, ["checkout", defaultBranch]); - yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); - - expect(result.hasOriginRemote).toBe(true); - expect(firstRemoteIndex).toBeGreaterThan(0); - expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe( - true, - ); - expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe( - true, - ); - expect( - result.branches.some( - (branch) => branch.name === "feature/local-only" && !branch.isRemote, - ), - ).toBe(true); - expect( - result.branches.some( - (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote, - ), - ).toBe(true); - }), - ); - - it.effect("includes remoteName metadata for remotes with slash in the name", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - const remoteName = "my-org/upstream"; - - yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(tmp, ["remote", "add", remoteName, remote]); - yield* git(tmp, ["push", "-u", remoteName, defaultBranch]); - - const remoteOnlyBranch = "feature/remote-with-remote-name"; - yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); - yield* git(tmp, ["push", "-u", remoteName, remoteOnlyBranch]); - yield* git(tmp, ["checkout", defaultBranch]); - yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const remoteBranch = result.branches.find( - (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, - ); - - expect(remoteBranch).toBeDefined(); - expect(remoteBranch?.isRemote).toBe(true); - expect(remoteBranch?.remoteName).toBe(remoteName); - }), - ); - - it.effect( - "filters branch queries before pagination and dedupes origin refs with local matches", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - - yield* git(remote, ["init", "--bare"]); - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); - yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); - - yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); - yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]); - yield* git(tmp, ["checkout", initialBranch]); - yield* git(tmp, ["branch", "-D", "feature/remote-only"]); - - const result = yield* (yield* GitCore).listBranches({ - cwd: tmp, - query: "feature/", - limit: 10, - }); - - expect(result.totalCount).toBe(2); - expect(result.nextCursor).toBeNull(); - expect(result.branches.map((branch) => branch.name)).toEqual([ - "feature/demo", - "origin/feature/remote-only", - ]); - }), - ); - }); - - // ── checkoutGitBranch ── - - describe("checkoutGitBranch", () => { - it.effect("checks out an existing branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature"); - }), - ); - - it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => - Effect.gen(function* () { - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); - - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature-behind"; - yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); - yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); - yield* git(clone, ["add", "feature.txt"]); - yield* git(clone, ["commit", "-m", "remote feature update"]); - yield* git(clone, ["push", "origin", featureBranch]); - - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - const core = yield* GitCore; - yield* Effect.promise(() => - vi.waitFor( - async () => { - const details = await runPromise(core.statusDetails(source)); - expect(details.branch).toBe(featureBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }, - { - timeout: 10_000, - interval: 100, - }, - ), - ); - }), - ); - - it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature-refresh-failure"; - yield* git(source, ["branch", featureBranch]); - yield* git(source, ["checkout", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - - const realGitCore = yield* GitCore; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - refreshFetchAttempts += 1; - return Effect.fail( - new GitCommandError({ - operation: "git.test.refreshFailure", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - return realGitCore.execute(input); - }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - const status = yield* core.statusDetails(source); - expect(refreshFetchAttempts).toBe(1); - expect(status.branch).toBe(featureBranch); - expect(status.upstreamRef).toBe(`origin/${featureBranch}`); - expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); - }), - ); - - it.effect("defers upstream refresh until statusDetails is requested", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature/scoped-fetch"; - yield* git(source, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - - const realGitCore = yield* GitCore; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - refreshFetchAttempts += 1; - return Effect.succeed({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - } - return realGitCore.execute(input); - }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); - expect(refreshFetchAttempts).toBe(0); - const status = yield* core.statusDetails(source); - expect(status.branch).toBe(featureBranch); - expect(refreshFetchAttempts).toBe(1); - }), - ); - - it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok( - input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", - ); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - expect(input.cwd).toBe("/repo"); - expect(input.args).toEqual([ - "--git-dir", - "/repo/.git", - "fetch", - "--quiet", - "--no-tags", - "origin", - ]); - return ok(); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" - : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - ); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in shared refresh cache test.", - }), - ); - }); - - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), - ); - - it.effect( - "briefly backs off failed upstream refreshes across sibling worktrees on one remote", - () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "origin/feature/pr-123\n" - : "origin/main\n", - ); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" - : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - ); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in refresh failure cooldown test.", - }), - ); - }); - - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), - ); - - it.effect("throws when branch does not exist", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - - it.effect("does not silently checkout a local branch when a remote ref no longer exists", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" }); - - const checkoutResult = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), - ); - expect(checkoutResult._tag).toBe("Failure"); - expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); - }), - ); - - it.effect("checks out a remote tracking branch when remote name contains slashes", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; - const remoteName = "my-org/upstream"; - const featureBranch = "feature"; - yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(source, remoteName, remote, remoteName); - yield* git(source, ["push", "-u", remoteName, defaultBranch]); - - yield* git(source, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature content\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["push", "-u", remoteName, featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - yield* git(source, ["branch", "-D", featureBranch]); - - const checkoutResult = yield* (yield* GitCore).checkoutBranch({ - cwd: source, - branch: `${remoteName}/${featureBranch}`, - }); - - expect(checkoutResult.branch).toBe("upstream/feature"); - expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); - const realGitCore = yield* GitCore; - let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchArgs = [...input.args]; - return Effect.succeed({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - } - return realGitCore.execute(input); - }); - - const status = yield* core.statusDetails(source); - expect(status.branch).toBe("upstream/feature"); - expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`); - expect(fetchArgs).toEqual([ - "--git-dir", - path.join(source, ".git"), - "fetch", - "--quiet", - "--no-tags", - remoteName, - ]); - }), - ); - - it.effect( - "falls back to detached checkout when --track would conflict with an existing local branch", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - // Keep local branch but remove tracking so `--track origin/` - // would attempt to create an already-existing local branch. - yield* git(source, ["branch", "--unset-upstream"]); - - yield* (yield* GitCore).checkoutBranch({ - cwd: source, - branch: `origin/${defaultBranch}`, - }); - - const core = yield* GitCore; - const status = yield* core.statusDetails(source); - expect(status.branch).toBeNull(); - }), - ); - - it.effect("throws when checkout would overwrite uncommitted changes", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); - - // Create a conflicting change: modify README on current branch - yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); - yield* git(tmp, ["add", "README.md"]); - - // First, checkout other branch cleanly - yield* git(tmp, ["stash"]); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); - yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "other change"]); - - // Go back to default branch - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => !b.current, - )!.name; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - - // Make uncommitted changes to the same file - yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); - - // Checkout should fail due to uncommitted changes - const result = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - }); - - // ── createGitBranch ── - - describe("createGitBranch", () => { - it.effect("creates a new branch visible in listGitBranches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); - }), - ); - - it.effect("throws when branch already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); - const result = yield* Effect.result( - (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - }); - - // ── renameGitBranch ── - - describe("renameGitBranch", () => { - it.effect("renames the current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "feature/old-name", - newBranch: "feature/new-name", - }); - - expect(renamed.branch).toBe("feature/new-name"); - - const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); - const current = branches.branches.find((branch) => branch.current); - expect(current?.name).toBe("feature/new-name"); - }), - ); - - it.effect("returns success without git invocation when old/new names match", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!; - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: current.name, - newBranch: current.name, - }); - - expect(renamed.branch).toBe(current.name); - }), - ); - - it.effect("appends numeric suffix when target branch already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "t3code/tmp-working", - newBranch: "t3code/feat/session", - }); - - expect(renamed.branch).toBe("t3code/feat/session-1"); - const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( - true, - ); - expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( - true, - ); - const current = branches.branches.find((branch) => branch.current); - expect(current?.name).toBe("t3code/feat/session-1"); - }), - ); - - it.effect("increments suffix until it finds an available branch name", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "t3code/tmp-working", - newBranch: "t3code/feat/session", - }); - - expect(renamed.branch).toBe("t3code/feat/session-2"); - }), - ); - - it.effect("uses '--' separator for branch rename arguments", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - - const realGitCore = yield* GitCore; - let renameArgs: ReadonlyArray | null = null; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "branch" && input.args[1] === "-m") { - renameArgs = [...input.args]; - } - return realGitCore.execute(input); - }); - - const renamed = yield* core.renameBranch({ - cwd: tmp, - oldBranch: "feature/old-name", - newBranch: "feature/new-name", - }); - - expect(renamed.branch).toBe("feature/new-name"); - expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]); - }), - ); - }); - - // ── createGitWorktree + removeGitWorktree ── - - describe("createGitWorktree", () => { - it.effect("creates a worktree with a new branch from the base branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "worktree-out"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-branch", - path: wtPath, - }); - - expect(result.worktree.path).toBe(wtPath); - expect(result.worktree.branch).toBe("wt-branch"); - expect(existsSync(wtPath)).toBe(true); - expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); - - // Clean up worktree before tmp dir disposal - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("worktree has the new branch checked out", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-check-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-check", - path: wtPath, - }); - - // Verify the worktree is on the new branch - const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); - expect(branchOutput).toBe("wt-check"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("creates a worktree for an existing branch when newBranch is omitted", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); - - const wtPath = path.join(tmp, "wt-existing"); - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: "feature/existing-worktree", - path: wtPath, - }); - - expect(result.worktree.path).toBe(wtPath); - expect(result.worktree.branch).toBe("feature/existing-worktree"); - const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); - expect(branchOutput).toBe("feature/existing-worktree"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("throws when new branch name already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); - - const wtPath = path.join(tmp, "wt-conflict"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const result = yield* Effect.result( - (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "existing", - path: wtPath, - }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - - it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-list-dir"); - const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: mainBranch, - newBranch: "wt-list", - path: wtPath, - }); - - // listGitBranches from the worktree should show wt-list as current - const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); - expect(wtBranches.isRepo).toBe(true); - const wtCurrent = wtBranches.branches.find((b) => b.current); - expect(wtCurrent!.name).toBe("wt-list"); - - // Main repo should still show the original branch as current - const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(mainBranch); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("removeGitWorktree cleans up the worktree", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-remove-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-remove", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - expect(existsSync(wtPath)).toBe(false); - }), - ); - - it.effect("removeGitWorktree force removes a dirty worktree", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-dirty-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-dirty", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); - - const failedRemove = yield* Effect.result( - (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }), - ); - expect(failedRemove._tag).toBe("Failure"); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true }); - expect(existsSync(wtPath)).toBe(false); - }), - ); - }); - - // ── Full flow: local branch checkout ── - - describe("full flow: local branch checkout", () => { - it.effect("init → commit → create branch → checkout → verify current", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature-login"); - }), - ); - }); - - // ── Full flow: worktree creation from base branch ── - - describe("full flow: worktree creation", () => { - it.effect("creates worktree with new branch from current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const wtPath = path.join(tmp, "my-worktree"); - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "feature-wt", - path: wtPath, - }); - - // Worktree exists - expect(existsSync(result.worktree.path)).toBe(true); - - // Main repo still on original branch - const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(currentBranch); - - // Worktree is on the new branch - const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); - expect(wtBranch).toBe("feature-wt"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - }); - - describe("fetchPullRequestBranch", () => { - it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const remoteDir = yield* makeTmpDir("git-remote-"); - yield* git(remoteDir, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remoteDir]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]); - yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n"); - yield* git(tmp, ["add", "pr-fetch.txt"]); - yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]); - yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]); - yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); - yield* git(tmp, ["checkout", initialBranch]); - - yield* (yield* GitCore).fetchPullRequestBranch({ - cwd: tmp, - prNumber: 55, - branch: "feature/pr-fetch", - }); - - const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]); - expect(localBranches).toContain("feature/pr-fetch"); - const currentBranch = yield* git(tmp, ["branch", "--show-current"]); - expect(currentBranch).toBe(initialBranch); - }), - ); - }); - - // ── Full flow: thread switching simulation ── - - describe("full flow: thread switching (checkout toggling)", () => { - it.effect("checkout a → checkout b → checkout a → current matches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" }); - - // Simulate switching to thread A's branch - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - let branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - - // Simulate switching to thread B's branch - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" }); - branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); - - // Switch back to thread A - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - }), - ); - }); - - // ── Full flow: checkout conflict ── - - describe("full flow: checkout conflict", () => { - it.effect("uncommitted changes prevent checkout to a diverged branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); - - // Make diverged branch have different file content - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); - yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "diverge"]); - - // Actually, let's just get back to the initial branch explicitly - const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); - - // Make local uncommitted changes to the same file - yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); - - // Attempt checkout should fail - const failedCheckout = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }), - ); - expect(failedCheckout._tag).toBe("Failure"); - - // Current branch should still be the initial one - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); - }), - ); - }); - - describe("GitCore", () => { - it.effect("supports branch lifecycle operations through the service API", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const core = yield* GitCore; - - yield* core.initRepo({ cwd: tmp }); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "# test\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "initial commit"]); - - yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" }); - const branches = yield* core.listBranches({ cwd: tmp }); - - expect(branches.isRepo).toBe(true); - expect( - branches.branches.find((branch: { current: boolean; name: string }) => branch.current) - ?.name, - ).toBe("feature/service-api"); - }), - ); - - it.effect( - "reuses an existing remote when the target URL only differs by a trailing slash after .git", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]); - - const remoteName = yield* core.ensureRemote({ - cwd: tmp, - preferredName: "origin", - url: "git@github.com:pingdotgg/t3code.git/", - }); - - expect(remoteName).toBe("origin"); - expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]); - }), - ); - - it.effect("reports status details and dirty state", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - const clean = yield* core.status({ cwd: tmp }); - expect(clean.hasWorkingTreeChanges).toBe(false); - expect(clean.branch).toBeTruthy(); - - yield* writeTextFile(path.join(tmp, "README.md"), "updated\n"); - const dirty = yield* core.statusDetails(tmp); - expect(dirty.hasWorkingTreeChanges).toBe(true); - }), - ); - - it.effect("returns a non-repo status for deleted directories", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const deletedDir = path.join(tmp, "deleted-repo"); - yield* makeDirectory(deletedDir); - yield* removePath(deletedDir); - const core = yield* GitCore; - - const status = yield* core.statusDetails(deletedDir); - const localStatus = yield* core.statusDetailsLocal(deletedDir); - - expect(status).toEqual({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - }); - expect(localStatus).toEqual(status); - }), - ); - - it.effect("computes ahead count against base branch when no upstream is configured", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); - yield* writeTextFile(path.join(tmp, "feature.txt"), "ahead of base\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature commit"]); - - const details = yield* core.statusDetails(tmp); - expect(details.branch).toBe("feature/no-upstream-ahead"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect( - "computes ahead count against origin/default when local default branch is missing", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); - yield* writeTextFile( - path.join(source, "feature.txt"), - `ahead of origin/${initialBranch}\n`, - ); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["branch", "-D", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe("feature/remote-base-only"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect( - "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const remoteName = "fork-seed"; - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", remoteName, remote]); - yield* git(source, ["push", "-u", remoteName, initialBranch]); - yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]); - yield* git(source, [ - "config", - "branch.feature/non-origin-merge-base.gh-merge-base", - `${remoteName}/${initialBranch}`, - ]); - yield* writeTextFile( - path.join(source, "feature.txt"), - `ahead of ${remoteName}/${initialBranch}\n`, - ); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["branch", "-D", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe("feature/non-origin-merge-base"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect("skips push when no upstream is configured and branch is not ahead of base", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); - - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("skipped_up_to_date"); - expect(pushed.branch).toBe("feature/no-upstream-no-ahead"); - expect(pushed.setUpstream).toBeUndefined(); - }), - ); - - it.effect("pushes with upstream setup when no comparable base branch exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(tmp, ["init", "--initial-branch=trunk"]); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); - yield* git(tmp, ["add", "README.md"]); - yield* git(tmp, ["commit", "-m", "initial"]); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["checkout", "-b", "feature/no-base"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe("origin/feature/no-base"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "origin/feature/no-base", - ); - }), - ); - - it.effect("pushes with upstream setup to the only configured non-origin remote", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(tmp, ["init", "--initial-branch=main"]); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); - yield* git(tmp, ["add", "README.md"]); - yield* git(tmp, ["commit", "-m", "initial"]); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "fork", remote]); - yield* git(tmp, ["checkout", "-b", "feature/fork-only"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe("fork/feature/fork-only"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "fork/feature/fork-only", - ); - }), - ); - - it.effect( - "pushes with upstream setup when comparable base exists but remote branch is missing", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - yield* writeTextFile(path.join(tmp, "default-ahead.txt"), "ahead on default\n"); - yield* git(tmp, ["add", "default-ahead.txt"]); - yield* git(tmp, ["commit", "-m", "default ahead"]); - - const featureBranch = "feature/publish-no-upstream"; - yield* git(tmp, ["checkout", "-b", featureBranch]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `origin/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", "origin", featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect("prefers branch pushRemote over origin when setting upstream", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const origin = yield* makeTmpDir(); - const fork = yield* makeTmpDir(); - yield* git(origin, ["init", "--bare"]); - yield* git(fork, ["init", "--bare"]); - - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(tmp, ["remote", "add", "origin", origin]); - yield* git(tmp, ["remote", "add", "fork", fork]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - const featureBranch = "feature/push-remote"; - yield* git(tmp, ["checkout", "-b", featureBranch]); - yield* git(tmp, ["config", `branch.${featureBranch}.pushRemote`, "fork"]); - yield* writeTextFile(path.join(tmp, "feature.txt"), "push to fork\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature commit"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe(`fork/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `fork/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", "fork", featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect( - "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const fork = yield* makeTmpDir(); - yield* git(fork, ["init", "--bare"]); - - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* git(tmp, ["remote", "add", "jasonLaster", fork]); - yield* git(tmp, ["checkout", "-b", "statemachine"]); - yield* writeTextFile(path.join(tmp, "fork.txt"), "fork branch\n"); - yield* git(tmp, ["add", "fork.txt"]); - yield* git(tmp, ["commit", "-m", "fork branch"]); - yield* git(tmp, ["push", "-u", "jasonLaster", "statemachine"]); - yield* git(tmp, ["checkout", initialBranch]); - yield* git(tmp, ["branch", "-D", "statemachine"]); - yield* git(tmp, [ - "checkout", - "-b", - "t3code/pr-488/statemachine", - "--track", - "jasonLaster/statemachine", - ]); - yield* git(tmp, ["config", "push.default", "current"]); - yield* writeTextFile(path.join(tmp, "fork.txt"), "updated fork branch\n"); - yield* git(tmp, ["add", "fork.txt"]); - yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(false); - expect(pushed.upstreamBranch).toBe("jasonLaster/statemachine"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "jasonLaster/statemachine", - ); - expect( - yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "statemachine"]), - ).toContain("statemachine"); - expect( - yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "t3code/pr-488/statemachine"]), - ).toBe(""); - }), - ); - - it.effect("pushes to the tracked upstream when the remote name contains slashes", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; - const remoteName = "my-org/upstream"; - const featureBranch = "feature/slash-remote-push"; - yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); - - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(tmp, remoteName, remote, remoteName); - yield* git(tmp, ["push", "-u", remoteName, initialBranch]); - - yield* git(tmp, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature base"]); - yield* git(tmp, ["push", "-u", remoteName, featureBranch]); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature update"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(false); - expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `${remoteName}/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect("includes command context when worktree removal fails", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - const missingWorktreePath = path.join(tmp, "missing-worktree"); - - const removeResult = yield* Effect.result( - core.removeWorktree({ cwd: tmp, path: missingWorktreePath }), - ); - expect(removeResult._tag).toBe("Failure"); - if (removeResult._tag !== "Failure") { - return; - } - const message = removeResult.failure.message; - expect(message).toContain("git worktree remove"); - expect(message).toContain(`cwd: ${tmp}`); - expect(message).toContain(missingWorktreePath); - }), - ); - - it.effect( - "refreshes upstream before statusDetails so behind count reflects remote updates", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, [ - "checkout", - "-B", - initialBranch, - "--track", - `origin/${initialBranch}`, - ]); - yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); - yield* git(clone, ["add", "CHANGELOG.md"]); - yield* git(clone, ["commit", "-m", "remote update"]); - yield* git(clone, ["push", "origin", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe(initialBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }), - ); - - it.effect("prepares commit context by auto-staging and creates commit", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary.length).toBeGreaterThan(0); - expect(context!.stagedPatch.length).toBeGreaterThan(0); - - const created = yield* core.commit(tmp, "Add README update", "- include updated content"); - expect(created.commitSha.length).toBeGreaterThan(0); - expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update"); - }), - ); - - it.effect("prepareCommitContext stages only selected files when filePaths provided", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); - yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); - - const context = yield* core.prepareCommitContext(tmp, ["a.txt"]); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("a.txt"); - expect(context!.stagedSummary).not.toContain("b.txt"); - - yield* core.commit(tmp, "Add only a.txt", ""); - - // b.txt should still be untracked after commit - const statusAfter = yield* git(tmp, ["status", "--porcelain"]); - expect(statusAfter).toContain("b.txt"); - expect(statusAfter).not.toContain("a.txt"); - }), - ); - - it.effect("prepareCommitContext stages everything when filePaths is undefined", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); - yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); - - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("a.txt"); - expect(context!.stagedSummary).toContain("b.txt"); - }), - ); - - it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); - - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("README.md"); - expect(context!.stagedPatch).toContain("[truncated]"); - }), - ); - - it.effect("readRangeContext truncates oversized diff patches instead of failing", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/large-range-context" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/large-range-context" }); - yield* writeTextFile(path.join(tmp, "large.txt"), buildLargeText()); - yield* git(tmp, ["add", "large.txt"]); - yield* git(tmp, ["commit", "-m", "Add large range context"]); - - const rangeContext = yield* core.readRangeContext(tmp, initialBranch); - expect(rangeContext.commitSummary).toContain("Add large range context"); - expect(rangeContext.diffSummary).toContain("large.txt"); - expect(rangeContext.diffPatch).toContain("[truncated]"); - }), - ); - - it.effect("pushes with upstream setup and then skips when up to date", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" }); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); - const core = yield* GitCore; - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - yield* core.commit(tmp, "Add feature file", ""); - - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "origin/feature/core-push", - ); - - const skipped = yield* core.pushCurrentBranch(tmp, null); - expect(skipped.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("pulls behind branch and then reports up-to-date", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); - yield* git(clone, ["add", "CHANGELOG.md"]); - yield* git(clone, ["commit", "-m", "remote update"]); - yield* git(clone, ["push", "origin", initialBranch]); - - const core = yield* GitCore; - const pulled = yield* core.pullCurrentBranch(source); - expect(pulled.status).toBe("pulled"); - expect((yield* core.statusDetails(source)).behindCount).toBe(0); - - const skipped = yield* core.pullCurrentBranch(source); - expect(skipped.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("top-level pullGitBranch rejects when no upstream exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(result.failure.message.toLowerCase()).toContain("no upstream"); - } - }), - ); - - it.effect("lists branches when recency lookup fails", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const realGitCore = yield* GitCore; - let didFailRecency = false; - const core = yield* makeIsolatedGitCore((input) => { - if (!didFailRecency && input.args[0] === "for-each-ref") { - didFailRecency = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRecency", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "timeout", - }), - ); - } - return realGitCore.execute(input); - }); - - const result = yield* core.listBranches({ cwd: tmp }); - - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThan(0); - expect(result.branches[0]?.current).toBe(true); - expect(didFailRecency).toBe(true); - }), - ); - - it.effect("falls back to empty remote branch data when remote lookups fail", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - - const realGitCore = yield* GitCore; - let didFailRemoteBranches = false; - let didFailRemoteNames = false; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args.join(" ") === "branch --no-color --no-column --remotes") { - didFailRemoteBranches = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteBranches", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - if (input.args.join(" ") === "remote") { - didFailRemoteNames = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteNames", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - return realGitCore.execute(input); - }); - - const result = yield* core.listBranches({ cwd: tmp }); - - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThan(0); - expect(result.branches.every((branch) => !branch.isRemote)).toBe(true); - expect(didFailRemoteBranches).toBe(true); - expect(didFailRemoteNames).toBe(true); - }), - ); - }); -}); diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts deleted file mode 100644 index 0ee4b3f09ac..00000000000 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { afterEach, expect, vi } from "vitest"; - -vi.mock("../../processRunner", () => ({ - runProcess: vi.fn(), -})); - -import { runProcess } from "../../processRunner"; -import { GitHubCli } from "../Services/GitHubCli.ts"; -import { GitHubCliLive } from "./GitHubCli.ts"; - -const mockedRunProcess = vi.mocked(runProcess); -const layer = it.layer(GitHubCliLive); - -afterEach(() => { - mockedRunProcess.mockReset(); -}); - -layer("GitHubCliLive", (it) => { - it.effect("parses pull request view output", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "OPEN", - mergedAt: null, - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - expect(mockedRunProcess).toHaveBeenCalledWith( - "gh", - [ - "pr", - "view", - "#42", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - expect.objectContaining({ cwd: "/repo" }), - ); - }), - ); - - it.effect("trims pull request fields decoded from gh json", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: " Add PR thread creation \n", - url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", - baseRefName: " main ", - headRefName: "\tfeature/pr-threads\t", - state: "OPEN", - mergedAt: null, - isCrossRepository: true, - headRepository: { - nameWithOwner: " octocat/codething-mvp ", - }, - headRepositoryOwner: { - login: " octocat ", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - }), - ); - - it.effect("skips invalid entries when parsing pr lists", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 0, - title: "invalid", - url: "https://github.com/pingdotgg/codething-mvp/pull/0", - baseRefName: "main", - headRefName: "feature/invalid", - }, - { - number: 43, - title: " Valid PR ", - url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", - baseRefName: " main ", - headRefName: " feature/pr-list ", - headRepository: { - nameWithOwner: " ", - }, - headRepositoryOwner: { - login: " ", - }, - }, - ]), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.listOpenPullRequests({ - cwd: "/repo", - headSelector: "feature/pr-list", - }); - }); - - assert.deepStrictEqual(result, [ - { - number: 43, - title: "Valid PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/43", - baseRefName: "main", - headRefName: "feature/pr-list", - state: "open", - }, - ]); - }), - ); - - it.effect("reads repository clone URLs", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "octocat/codething-mvp", - }); - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }); - }), - ); - - it.effect("surfaces a friendly error when the pull request is not found", () => - Effect.gen(function* () { - mockedRunProcess.mockRejectedValueOnce( - new Error( - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - ), - ); - - const error = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "4888", - }); - }).pipe(Effect.flip); - - assert.equal(error.message.includes("Pull request not found"), true); - }), - ); -}); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts deleted file mode 100644 index 1a687b0e8dd..00000000000 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; - -import { runProcess } from "../../processRunner"; -import { GitHubCliError } from "@t3tools/contracts"; -import { - GitHubCli, - type GitHubRepositoryCloneUrls, - type GitHubCliShape, -} from "../Services/GitHubCli.ts"; -import { - decodeGitHubPullRequestJson, - decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, -} from "../githubPullRequests.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; - -function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: gh")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); - } - - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") - ) { - return new GitHubCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: `GitHub CLI command failed: ${error.message}`, - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: "GitHub CLI command failed.", - cause: error, - }); -} - -const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ - nameWithOwner: TrimmedNonEmptyString, - url: TrimmedNonEmptyString, - sshUrl: TrimmedNonEmptyString, -}); - -function normalizeRepositoryCloneUrls( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { - nameWithOwner: raw.nameWithOwner, - url: raw.url, - sshUrl: raw.sshUrl, - }; -} - -function decodeGitHubJson( - raw: string, - schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", - invalidDetail: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitHubCliError({ - operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, - }), - ), - ); -} - -const makeGitHubCli = Effect.sync(() => { - const execute: GitHubCliShape["execute"] = (input) => - Effect.tryPromise({ - try: () => - runProcess("gh", input.args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }), - catch: (error) => normalizeGitHubCliError("execute", error), - }); - - const service = { - execute, - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([]) - : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubCliError({ - operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed( - decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), - ); - }), - ), - ), - ), - getPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - input.reference, - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubCliError({ - operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed( - (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), - ); - }), - ), - ), - ), - getRepositoryCloneUrls: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitHub CLI returned invalid repository JSON.", - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), - createPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "create", - "--base", - input.baseBranch, - "--head", - input.headSelector, - "--title", - input.title, - "--body-file", - input.bodyFile, - ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], - }).pipe( - Effect.map((value) => { - const trimmed = value.stdout.trim(); - return trimmed.length > 0 ? trimmed : null; - }), - ), - checkoutPullRequest: (input) => - execute({ - cwd: input.cwd, - args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], - }).pipe(Effect.asVoid), - } satisfies GitHubCliShape; - - return service; -}); - -export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts deleted file mode 100644 index 3ad7d095d8d..00000000000 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { realpathSync } from "node:fs"; - -import { - Duration, - Effect, - Exit, - Fiber, - Layer, - PubSub, - Ref, - Scope, - Stream, - SynchronizedRef, -} from "effect"; -import type { - GitStatusInput, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusStreamEvent, -} from "@t3tools/contracts"; -import { mergeGitStatusParts } from "@t3tools/shared/git"; - -import { - GitStatusBroadcaster, - type GitStatusBroadcasterShape, -} from "../Services/GitStatusBroadcaster.ts"; -import { GitManager } from "../Services/GitManager.ts"; - -const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); - -interface GitStatusChange { - readonly cwd: string; - readonly event: GitStatusStreamEvent; -} - -interface CachedValue { - readonly fingerprint: string; - readonly value: T; -} - -interface CachedGitStatus { - readonly local: CachedValue | null; - readonly remote: CachedValue | null; -} - -interface ActiveRemotePoller { - readonly fiber: Fiber.Fiber; - readonly subscriberCount: number; -} - -function normalizeCwd(cwd: string): string { - try { - return realpathSync.native(cwd); - } catch { - return cwd; - } -} - -function fingerprintStatusPart(status: unknown): string { - return JSON.stringify(status); -} - -export const GitStatusBroadcasterLive = Layer.effect( - GitStatusBroadcaster, - Effect.gen(function* () { - const gitManager = yield* GitManager; - const changesPubSub = yield* Effect.acquireRelease( - PubSub.unbounded(), - (pubsub) => PubSub.shutdown(pubsub), - ); - const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const cacheRef = yield* Ref.make(new Map()); - const pollersRef = yield* SynchronizedRef.make(new Map()); - - const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); - }); - - const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( - cwd: string, - local: GitStatusLocalResult, - options?: { publish?: boolean }, - ) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local: nextLocal, - }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; - }); - - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "localUpdated", - local, - }, - }); - } - - return local; - }); - - const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( - cwd: string, - remote: GitStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; - }); - - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "remoteUpdated", - remote, - }, - }); - } - - return remote; - }); - - const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { - const local = yield* gitManager.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); - }); - - const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { - const remote = yield* gitManager.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote); - }); - - const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { - const cached = yield* getCachedStatus(cwd); - if (cached?.local) { - return cached.local.value; - } - return yield* loadLocalStatus(cwd); - }); - - const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { - const cached = yield* getCachedStatus(cwd); - if (cached?.remote) { - return cached.remote.value; - } - return yield* loadRemoteStatus(cwd); - }); - - const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( - input: GitStatusInput, - ) { - const normalizedCwd = normalizeCwd(input.cwd); - const [local, remote] = yield* Effect.all([ - getOrLoadLocalStatus(normalizedCwd), - getOrLoadRemoteStatus(normalizedCwd), - ]); - return mergeGitStatusParts(local, remote); - }); - - const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( - "refreshLocalStatus", - )(function* (cwd) { - const normalizedCwd = normalizeCwd(cwd); - yield* gitManager.invalidateLocalStatus(normalizedCwd); - const local = yield* gitManager.localStatus({ cwd: normalizedCwd }); - return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true }); - }); - - const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { - yield* gitManager.invalidateRemoteStatus(cwd); - const remote = yield* gitManager.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); - }); - - const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( - function* (cwd) { - const normalizedCwd = normalizeCwd(cwd); - const [local, remote] = yield* Effect.all([ - refreshLocalStatus(normalizedCwd), - refreshRemoteStatus(normalizedCwd), - ]); - return mergeGitStatusParts(local, remote); - }, - ); - - const makeRemoteRefreshLoop = (cwd: string) => { - const logRefreshFailure = (error: Error) => - Effect.logWarning("git remote status refresh failed", { - cwd, - detail: error.message, - }); - - return refreshRemoteStatus(cwd).pipe( - Effect.catch(logRefreshFailure), - Effect.andThen( - Effect.forever( - Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( - Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), - ), - ), - ), - ); - }; - - const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { - 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 makeRemoteRefreshLoop(cwd).pipe( - Effect.forkIn(broadcasterScope), - Effect.map((fiber) => { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - fiber, - subscriberCount: 1, - }); - return [undefined, nextPollers] as const; - }), - ); - }); - }); - - const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (!existing) { - return [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; - } - - const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; - }); - - if (pollerToInterrupt) { - yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); - } - }); - - const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => - Stream.unwrap( - Effect.gen(function* () { - const normalizedCwd = normalizeCwd(input.cwd); - const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); - const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; - yield* retainRemotePoller(normalizedCwd); - - const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); - - return Stream.concat( - Stream.make({ - _tag: "snapshot" as const, - local: initialLocal, - remote: initialRemote, - }), - Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === normalizedCwd), - Stream.map((event) => event.event), - ), - ).pipe(Stream.ensuring(release)); - }), - ); - - return { - getStatus, - refreshLocalStatus, - refreshStatus, - streamStatus, - } satisfies GitStatusBroadcasterShape; - }), -); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts deleted file mode 100644 index 5372bc13491..00000000000 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * RoutingTextGeneration – Dispatches text generation requests to either the - * Codex CLI or Claude CLI implementation based on the provider in each - * request input. - * - * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to - * the Claude layer; for any other value (including the default `undefined`) it - * falls through to the Codex layer. - * - * @module RoutingTextGeneration - */ -import { Effect, Layer, Context } from "effect"; - -import { - TextGeneration, - type TextGenerationProvider, - type TextGenerationShape, -} from "../Services/TextGeneration.ts"; -import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; -import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; - -// --------------------------------------------------------------------------- -// Internal service tags so both concrete layers can coexist. -// --------------------------------------------------------------------------- - -class CodexTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/CodexTextGen", -) {} - -class ClaudeTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", -) {} - -// --------------------------------------------------------------------------- -// Routing implementation -// --------------------------------------------------------------------------- - -const makeRoutingTextGeneration = Effect.gen(function* () { - const codex = yield* CodexTextGen; - const claude = yield* ClaudeTextGen; - - const route = (provider?: TextGenerationProvider): TextGenerationShape => - provider === "claudeAgent" ? claude : codex; - - return { - generateCommitMessage: (input) => - route(input.modelSelection.provider).generateCommitMessage(input), - generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), - generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), - generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input), - } satisfies TextGenerationShape; -}); - -const InternalCodexLayer = Layer.effect( - CodexTextGen, - Effect.gen(function* () { - const svc = yield* TextGeneration; - return svc; - }), -).pipe(Layer.provide(CodexTextGenerationLive)); - -const InternalClaudeLayer = Layer.effect( - ClaudeTextGen, - Effect.gen(function* () { - const svc = yield* TextGeneration; - return svc; - }), -).pipe(Layer.provide(ClaudeTextGenerationLive)); - -export const RoutingTextGenerationLive = Layer.effect( - TextGeneration, - makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts deleted file mode 100644 index 9f3bc0b9b91..00000000000 --- a/apps/server/src/git/Services/GitCore.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * GitCore - Effect service contract for low-level Git operations. - * - * Wraps core repository primitives used by higher-level orchestration - * services and WebSocket routes. - * - * @module GitCore - */ -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { - GitCheckoutInput, - GitCheckoutResult, - GitCreateBranchInput, - GitCreateBranchResult, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, - GitPullResult, - GitRemoveWorktreeInput, - GitStatusInput, - GitStatusResult, -} from "@t3tools/contracts"; - -import type { GitCommandError } from "@t3tools/contracts"; - -export interface ExecuteGitInput { - readonly operation: string; - readonly cwd: string; - readonly args: ReadonlyArray; - readonly stdin?: string; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; - readonly truncateOutputAtMaxBytes?: boolean; - readonly progress?: ExecuteGitProgress; -} - -export interface ExecuteGitResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; - readonly stdoutTruncated: boolean; - readonly stderrTruncated: boolean; -} - -export interface GitStatusDetails extends Omit { - upstreamRef: string | null; -} - -export interface GitPreparedCommitContext { - stagedSummary: string; - stagedPatch: string; -} - -export interface ExecuteGitProgress { - readonly onStdoutLine?: (line: string) => Effect.Effect; - readonly onStderrLine?: (line: string) => Effect.Effect; - readonly onHookStarted?: (hookName: string) => Effect.Effect; - readonly onHookFinished?: (input: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => Effect.Effect; -} - -export interface GitCommitProgress { - readonly onOutputLine?: (input: { - stream: "stdout" | "stderr"; - text: string; - }) => Effect.Effect; - readonly onHookStarted?: (hookName: string) => Effect.Effect; - readonly onHookFinished?: (input: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => Effect.Effect; -} - -export interface GitCommitOptions { - readonly timeoutMs?: number; - readonly progress?: GitCommitProgress; -} - -export interface GitPushResult { - status: "pushed" | "skipped_up_to_date"; - branch: string; - upstreamBranch?: string | undefined; - setUpstream?: boolean | undefined; -} - -export interface GitRangeContext { - commitSummary: string; - diffSummary: string; - diffPatch: string; -} - -export interface GitListWorkspaceFilesResult { - readonly paths: ReadonlyArray; - readonly truncated: boolean; -} - -export interface GitRenameBranchInput { - cwd: string; - oldBranch: string; - newBranch: string; -} - -export interface GitRenameBranchResult { - branch: string; -} - -export interface GitFetchPullRequestBranchInput { - cwd: string; - prNumber: number; - branch: string; -} - -export interface GitEnsureRemoteInput { - cwd: string; - preferredName: string; - url: string; -} - -export interface GitFetchRemoteBranchInput { - cwd: string; - remoteName: string; - remoteBranch: string; - localBranch: string; -} - -export interface GitSetBranchUpstreamInput { - cwd: string; - branch: string; - remoteName: string; - remoteBranch: string; -} - -/** - * GitCoreShape - Service API for low-level Git repository interactions. - */ -export interface GitCoreShape { - /** - * Execute a raw Git command. - */ - readonly execute: (input: ExecuteGitInput) => Effect.Effect; - - /** - * Read Git status for a repository. - */ - readonly status: (input: GitStatusInput) => Effect.Effect; - - /** - * Read detailed working tree / branch status for a repository. - */ - readonly statusDetails: (cwd: string) => Effect.Effect; - - /** - * Read detailed working tree / branch status without refreshing remote tracking refs. - */ - readonly statusDetailsLocal: (cwd: string) => Effect.Effect; - - /** - * Build staged change context for commit generation. - */ - readonly prepareCommitContext: ( - cwd: string, - filePaths?: readonly string[], - ) => Effect.Effect; - - /** - * Create a commit with provided subject/body. - */ - readonly commit: ( - cwd: string, - subject: string, - body: string, - options?: GitCommitOptions, - ) => Effect.Effect<{ commitSha: string }, GitCommandError>; - - /** - * Push current branch, setting upstream if needed. - */ - readonly pushCurrentBranch: ( - cwd: string, - fallbackBranch: string | null, - ) => Effect.Effect; - - /** - * Collect commit/diff context between base branch and current HEAD. - */ - readonly readRangeContext: ( - cwd: string, - baseBranch: string, - ) => Effect.Effect; - - /** - * Read a Git config value from the local repository. - */ - readonly readConfigValue: ( - cwd: string, - key: string, - ) => Effect.Effect; - - /** - * Determine whether the provided cwd is inside a git work tree. - */ - readonly isInsideWorkTree: (cwd: string) => Effect.Effect; - - /** - * List tracked and untracked workspace file paths relative to cwd. - */ - readonly listWorkspaceFiles: ( - cwd: string, - ) => Effect.Effect; - - /** - * Remove gitignored paths from a relative path list. - */ - readonly filterIgnoredPaths: ( - cwd: string, - relativePaths: ReadonlyArray, - ) => Effect.Effect, GitCommandError>; - - /** - * List local + remote branches and branch metadata. - */ - readonly listBranches: ( - input: GitListBranchesInput, - ) => Effect.Effect; - - /** - * Pull current branch from upstream using fast-forward only. - */ - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - - /** - * Create a worktree and branch from a base branch. - */ - readonly createWorktree: ( - input: GitCreateWorktreeInput, - ) => Effect.Effect; - - /** - * Materialize a GitHub pull request head as a local branch without switching checkout. - */ - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, - ) => Effect.Effect; - - /** - * Ensure a named remote exists for the provided URL, returning the reused or created remote name. - */ - readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; - - /** - * Fetch a remote branch into a local branch without checkout. - */ - readonly fetchRemoteBranch: ( - input: GitFetchRemoteBranchInput, - ) => Effect.Effect; - - /** - * Set the upstream tracking branch for a local branch. - */ - readonly setBranchUpstream: ( - input: GitSetBranchUpstreamInput, - ) => Effect.Effect; - - /** - * Remove an existing worktree. - */ - readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; - - /** - * Rename an existing local branch. - */ - readonly renameBranch: ( - input: GitRenameBranchInput, - ) => Effect.Effect; - - /** - * Create a local branch. - */ - readonly createBranch: ( - input: GitCreateBranchInput, - ) => Effect.Effect; - - /** - * Checkout an existing branch and refresh its upstream metadata in background. - */ - readonly checkoutBranch: ( - input: GitCheckoutInput, - ) => Effect.Effect; - - /** - * Initialize a repository in the provided directory. - */ - readonly initRepo: (input: GitInitInput) => Effect.Effect; - - /** - * List local branch names (short format). - */ - readonly listLocalBranchNames: (cwd: string) => Effect.Effect; -} - -/** - * GitCore - Service tag for low-level Git repository operations. - */ -export class GitCore extends Context.Service()("t3/git/Services/GitCore") {} diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts deleted file mode 100644 index 216c24bf7c5..00000000000 --- a/apps/server/src/git/Services/GitHubCli.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * GitHubCli - Effect service contract for `gh` process interactions. - * - * Provides thin command execution helpers used by Git workflow orchestration. - * - * @module GitHubCli - */ -import { Context } from "effect"; -import type { Effect } from "effect"; - -import type { ProcessRunResult } from "../../processRunner"; -import type { GitHubCliError } from "@t3tools/contracts"; - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state?: "open" | "closed" | "merged"; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -/** - * GitHubCliShape - Service API for executing GitHub CLI commands. - */ -export interface GitHubCliShape { - /** - * Execute a GitHub CLI command and return full process output. - */ - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - /** - * List open pull requests for a head branch. - */ - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; - - /** - * Resolve a pull request by URL, number, or branch-ish identifier. - */ - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - /** - * Resolve clone URLs for a GitHub repository. - */ - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - /** - * Create a pull request from branch context and body file. - */ - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - /** - * Resolve repository default branch through GitHub metadata. - */ - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - /** - * Checkout a pull request into the current repository worktree. - */ - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -/** - * GitHubCli - Service tag for GitHub CLI process execution. - */ -export class GitHubCli extends Context.Service()( - "t3/git/Services/GitHubCli", -) {} diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts deleted file mode 100644 index 29c762195e5..00000000000 --- a/apps/server/src/git/Services/GitManager.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * GitManager - Effect service contract for stacked Git workflows. - * - * Orchestrates status inspection and commit/push/PR flows by composing - * lower-level Git and external tool services. - * - * @module GitManager - */ -import { - GitActionProgressEvent, - GitPreparePullRequestThreadInput, - GitPreparePullRequestThreadResult, - GitPullRequestRefInput, - GitResolvePullRequestResult, - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusInput, - GitStatusResult, -} from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { GitManagerServiceError } from "@t3tools/contracts"; - -export interface GitActionProgressReporter { - readonly publish: (event: GitActionProgressEvent) => Effect.Effect; -} - -export interface GitRunStackedActionOptions { - readonly actionId?: string; - readonly progressReporter?: GitActionProgressReporter; -} - -/** - * GitManagerShape - Service API for high-level Git workflow actions. - */ -export interface GitManagerShape { - /** - * Read current repository Git status plus open PR metadata when available. - */ - readonly status: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Read local repository status without remote hosting enrichment. - */ - readonly localStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Read remote tracking / PR status for a repository. - */ - readonly remoteStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Clear any cached local status snapshot for a repository. - */ - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - - /** - * Clear any cached remote status snapshot for a repository. - */ - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - - /** - * Clear any cached status snapshot for a repository so the next read is fresh. - */ - readonly invalidateStatus: (cwd: string) => Effect.Effect; - - /** - * Resolve a pull request by URL/number against the current repository. - */ - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - - /** - * Prepare a new thread workspace from a pull request in local or worktree mode. - */ - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - - /** - * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). - * When `featureBranch` is set, creates and checks out a feature branch first. - */ - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; -} - -/** - * GitManager - Service tag for stacked Git workflow orchestration. - */ -export class GitManager extends Context.Service()( - "t3/git/Services/GitManager", -) {} diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts deleted file mode 100644 index 647f8408242..00000000000 --- a/apps/server/src/git/Services/GitStatusBroadcaster.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Context } from "effect"; -import type { Effect, Stream } from "effect"; -import type { - GitManagerServiceError, - GitStatusInput, - GitStatusLocalResult, - GitStatusResult, - GitStatusStreamEvent, -} from "@t3tools/contracts"; - -export interface GitStatusBroadcasterShape { - readonly getStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - readonly refreshLocalStatus: ( - cwd: string, - ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; - readonly streamStatus: ( - input: GitStatusInput, - ) => Stream.Stream; -} - -export class GitStatusBroadcaster extends Context.Service< - GitStatusBroadcaster, - GitStatusBroadcasterShape ->()("t3/git/Services/GitStatusBroadcaster") {} diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 4a7931c74b2..b414daaa0a4 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,123 +1,7 @@ -/** - * Shared utilities for text generation layers (Codex, Claude, etc.). - * - * @module textGenerationUtils - */ -import { Schema } from "effect"; - -import { TextGenerationError } from "@t3tools/contracts"; - +// @effect-diagnostics nodeBuiltinImport:off import { existsSync } from "node:fs"; import { join } from "node:path"; export function isGitRepository(cwd: string): boolean { return existsSync(join(cwd, ".git")); } - -/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ -export function toJsonSchemaObject(schema: Schema.Top): unknown { - const document = Schema.toJsonSchemaDocument(schema); - if (document.definitions && Object.keys(document.definitions).length > 0) { - return { ...document.schema, $defs: document.definitions }; - } - return document.schema; -} - -/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ -export function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - const truncated = value.slice(0, maxChars); - return `${truncated}\n\n[truncated]`; -} - -/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ -export function sanitizeCommitSubject(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); - if (withoutTrailingPeriod.length === 0) { - return "Update project files"; - } - - if (withoutTrailingPeriod.length <= 72) { - return withoutTrailingPeriod; - } - return withoutTrailingPeriod.slice(0, 72).trimEnd(); -} - -/** Normalise a raw PR title to a single line with a sensible fallback. */ -export function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - if (singleLine.length > 0) { - return singleLine; - } - return "Update project changes"; -} - -/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ -export function sanitizeThreadTitle(raw: string): string { - const normalized = raw - .trim() - .split(/\r?\n/g)[0] - ?.trim() - .replace(/^['"`]+|['"`]+$/g, "") - .trim() - .replace(/\s+/g, " "); - - if (!normalized || normalized.trim().length === 0) { - return "New thread"; - } - - if (normalized.length <= 50) { - return normalized; - } - - return `${normalized.slice(0, 47).trimEnd()}...`; -} - -/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ -function cliLabel(cliName: string): string { - const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); - return `${capitalized} CLI (\`${cliName}\`)`; -} - -/** - * Normalize an unknown error from a CLI text generation process into a - * typed `TextGenerationError`. Parameterized by CLI name so both Codex - * and Claude (and future providers) can share the same logic. - */ -export function normalizeCliError( - cliName: string, - operation: string, - error: unknown, - fallback: string, -): TextGenerationError { - if (Schema.is(TextGenerationError)(error)) { - return error; - } - - if (error instanceof Error) { - const lower = error.message.toLowerCase(); - if ( - error.message.includes(`Command not found: ${cliName}`) || - lower.includes(`spawn ${cliName}`) || - lower.includes("enoent") - ) { - return new TextGenerationError({ - operation, - detail: `${cliLabel(cliName)} is required but not available on PATH.`, - cause: error, - }); - } - return new TextGenerationError({ - operation, - detail: `${fallback}: ${error.message}`, - cause: error, - }); - } - - return new TextGenerationError({ - operation, - detail: fallback, - cause: error, - }); -} diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ed403894291..f287b4c59bf 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,10 @@ import Mime from "@effect/platform-node/Mime"; -import { Data, Effect, FileSystem, Option, Path } from "effect"; +import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import { cast } from "effect/Function"; import { HttpBody, @@ -15,15 +20,19 @@ import { ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, resolveAttachmentRelativePath, -} from "./attachmentPaths"; -import { resolveAttachmentPathById } from "./attachmentStore"; -import { resolveStaticDir, ServerConfig } from "./config"; -import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; +} from "./attachmentPaths.ts"; +import { resolveAttachmentPathById } from "./attachmentStore.ts"; +import { resolveStaticDir, ServerConfig } from "./config.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; +import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { respondToAuthError } from "./auth/http.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { + browserApiCorsAllowedHeaders, + browserApiCorsAllowedMethods, + browserApiCorsHeaders, +} from "./httpCors.ts"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; @@ -31,8 +40,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); export const browserApiCorsLayer = HttpRouter.cors({ - allowedMethods: ["GET", "POST", "OPTIONS"], - allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], + allowedMethods: [...browserApiCorsAllowedMethods], + allowedHeaders: [...browserApiCorsAllowedHeaders], maxAge: 600, }); @@ -65,7 +74,10 @@ export const serverEnvironmentRouteLayer = HttpRouter.add( const descriptor = yield* Effect.service(ServerEnvironment).pipe( Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), ); - return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 }); + return HttpServerResponse.jsonUnsafe(descriptor, { + status: 200, + headers: browserApiCorsHeaders, + }); }), ); @@ -226,6 +238,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts new file mode 100644 index 00000000000..e44486d3c4b --- /dev/null +++ b/apps/server/src/httpCors.ts @@ -0,0 +1,13 @@ +export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const; +export const browserApiCorsAllowedHeaders = [ + "authorization", + "b3", + "traceparent", + "content-type", +] as const; + +export const browserApiCorsHeaders = { + "access-control-allow-origin": "*", + "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "), + "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "), +} as const; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index e3f190ff061..188a8d32d18 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -2,8 +2,14 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; -import { ServerConfig } from "./config"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ServerConfig } from "./config.ts"; import { DEFAULT_KEYBINDINGS, @@ -13,10 +19,16 @@ import { compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, -} from "./keybindings"; +} from "./keybindings.ts"; import { KeybindingsConfigError } from "@t3tools/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); +const encodeKeybindingsConfigJson = Schema.encodeEffect(KeybindingsConfigJson); +const decodeKeybindingsConfigJson = Schema.decodeUnknownEffect(KeybindingsConfigJson); +const encodeResolvedKeybindingFromConfig = Schema.encodeEffect(ResolvedKeybindingFromConfig); +const decodeResolvedKeybindingFromConfigExit = Schema.decodeUnknownExit( + ResolvedKeybindingFromConfig, +); const makeKeybindingsLayer = () => { return KeybindingsLive.pipe( Layer.provideMerge( @@ -39,7 +51,7 @@ const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRu Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules); + const encoded = yield* encodeKeybindingsConfigJson(rules); yield* fileSystem.makeDirectory(path.dirname(configPath), { recursive: true }); yield* fileSystem.writeFileString(configPath, encoded); }); @@ -48,7 +60,7 @@ const readKeybindingsConfig = (configPath: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const rawConfig = yield* fileSystem.readFileString(configPath); - return yield* Schema.decodeUnknownEffect(KeybindingsConfigJson)(rawConfig); + return yield* decodeKeybindingsConfigJson(rawConfig); }); it.layer(NodeServices.layer)("keybindings", (it) => { @@ -105,7 +117,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("encodes resolved plus-key shortcuts", () => Effect.gen(function* () { - const encoded = yield* Schema.encodeEffect(ResolvedKeybindingFromConfig)({ + const encoded = yield* encodeResolvedKeybindingFromConfig({ command: "terminal.toggle", shortcut: { key: "+", @@ -151,7 +163,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("formats invalid resolved keybinding rules with the custom message", () => Effect.sync(() => { - const result = Schema.decodeUnknownExit(ResolvedKeybindingFromConfig)({ + const result = decodeResolvedKeybindingFromConfigExit({ key: "mod+shift+d+o", command: "terminal.new", }); @@ -192,6 +204,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); 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("modelPicker.jump.1"), "mod+1"); + assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), ); @@ -226,6 +241,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const { keybindingsConfigPath } = yield* ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+shift+d+o", command: "terminal.new" }, @@ -351,7 +367,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("replaces existing custom keybinding for the same command", () => + it.effect("appends additional custom keybindings for the same command", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ @@ -365,6 +381,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); }); + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("replaces only the targeted custom keybinding", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+r", + command: "script.run-tests.run", + replace: { key: "mod+r", command: "script.run-tests.run" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [ + { key: "mod+shift+r", command: "script.run-tests.run" }, + { key: "mod+alt+r", command: "script.run-tests.run" }, + ]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("removes only the targeted custom keybinding", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.removeKeybindingRule({ + key: "mod+r", + command: "script.run-tests.run", + }); + }); + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); const persistedView = persisted.map(({ key, command }) => ({ key, command })); assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index b473f77ca1b..a197984f23a 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -13,305 +13,50 @@ import { KeybindingShortcut, KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, - MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, - THREAD_JUMP_KEYBINDING_COMMANDS, + type ServerRemoveKeybindingInput, + type ServerUpsertKeybindingInput, type ServerConfigIssue, } from "@t3tools/contracts"; -import { Mutable } from "effect/Types"; -import { - Array, - Cache, - Cause, - Deferred, - Duration, - Effect, - Exit, - FileSystem, - Path, - Layer, - Option, - Predicate, - PubSub, - Schema, - SchemaGetter, - SchemaIssue, - SchemaTransformation, - Ref, - Context, - Scope, - Stream, -} from "effect"; +import * as Array from "effect/Array"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +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 FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import * as Ref from "effect/Ref"; +import * as Context from "effect/Context"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; - -type WhenToken = - | { type: "identifier"; value: string } - | { type: "not" } - | { type: "and" } - | { type: "or" } - | { type: "lparen" } - | { type: "rparen" }; - -export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ - { 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+d", command: "diff.toggle", when: "!terminalFocus" }, - { 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" }, - { key: "mod+shift+[", command: "thread.previous" }, - { key: "mod+shift+]", command: "thread.next" }, - ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ - key: `mod+${index + 1}`, - command, - })), -]; - -function normalizeKeyToken(token: string): string { - if (token === "space") return " "; - if (token === "esc") return "escape"; - return token; -} - -/** @internal - Exported for testing */ -export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { - const rawTokens = value - .toLowerCase() - .split("+") - .map((token) => token.trim()); - const tokens = [...rawTokens]; - let trailingEmptyCount = 0; - while (tokens[tokens.length - 1] === "") { - trailingEmptyCount += 1; - tokens.pop(); - } - if (trailingEmptyCount > 0) { - tokens.push("+"); - } - if (tokens.some((token) => token.length === 0)) { - return null; - } - if (tokens.length === 0) return null; - - let key: string | null = null; - let metaKey = false; - let ctrlKey = false; - let shiftKey = false; - let altKey = false; - let modKey = false; - - for (const token of tokens) { - switch (token) { - case "cmd": - case "meta": - metaKey = true; - break; - case "ctrl": - case "control": - ctrlKey = true; - break; - case "shift": - shiftKey = true; - break; - case "alt": - case "option": - altKey = true; - break; - case "mod": - modKey = true; - break; - default: { - if (key !== null) return null; - key = normalizeKeyToken(token); - } - } - } - - if (key === null) return null; - return { - key, - metaKey, - ctrlKey, - shiftKey, - altKey, - modKey, - }; -} - -function tokenizeWhenExpression(expression: string): WhenToken[] | null { - const tokens: WhenToken[] = []; - let index = 0; - - while (index < expression.length) { - const current = expression[index]; - if (!current) break; - - if (/\s/.test(current)) { - index += 1; - continue; - } - if (expression.startsWith("&&", index)) { - tokens.push({ type: "and" }); - index += 2; - continue; - } - if (expression.startsWith("||", index)) { - tokens.push({ type: "or" }); - index += 2; - continue; - } - if (current === "!") { - tokens.push({ type: "not" }); - index += 1; - continue; - } - if (current === "(") { - tokens.push({ type: "lparen" }); - index += 1; - continue; - } - if (current === ")") { - tokens.push({ type: "rparen" }); - index += 1; - continue; - } - - const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); - if (!identifier) { - return null; - } - tokens.push({ type: "identifier", value: identifier[0] }); - index += identifier[0].length; - } - - return tokens; -} - -function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { - const tokens = tokenizeWhenExpression(expression); - if (!tokens || tokens.length === 0) return null; - let index = 0; - - const parsePrimary = (depth: number): KeybindingWhenNode | null => { - if (depth > MAX_WHEN_EXPRESSION_DEPTH) { - return null; - } - const token = tokens[index]; - if (!token) return null; - - if (token.type === "identifier") { - index += 1; - return { type: "identifier", name: token.value }; - } - - if (token.type === "lparen") { - index += 1; - const expressionNode = parseOr(depth + 1); - const closeToken = tokens[index]; - if (!expressionNode || !closeToken || closeToken.type !== "rparen") { - return null; - } - index += 1; - return expressionNode; - } - - return null; - }; - - const parseUnary = (depth: number): KeybindingWhenNode | null => { - let notCount = 0; - while (tokens[index]?.type === "not") { - index += 1; - notCount += 1; - if (notCount > MAX_WHEN_EXPRESSION_DEPTH) { - return null; - } - } - - let node = parsePrimary(depth); - if (!node) return null; - - while (notCount > 0) { - node = { type: "not", node }; - notCount -= 1; - } - - return node; - }; - - const parseAnd = (depth: number): KeybindingWhenNode | null => { - let left = parseUnary(depth); - if (!left) return null; - - while (tokens[index]?.type === "and") { - index += 1; - const right = parseUnary(depth); - if (!right) return null; - left = { type: "and", left, right }; - } - - return left; - }; - - const parseOr = (depth: number): KeybindingWhenNode | null => { - let left = parseAnd(depth); - if (!left) return null; - - while (tokens[index]?.type === "or") { - index += 1; - const right = parseAnd(depth); - if (!right) return null; - left = { type: "or", left, right }; - } - - return left; - }; - - const ast = parseOr(0); - if (!ast || index !== tokens.length) return null; - return ast; -} - -/** @internal - Exported for testing */ -export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { - const shortcut = parseKeybindingShortcut(rule.key); - if (!shortcut) return null; - - if (rule.when !== undefined) { - const whenAst = parseKeybindingWhenExpression(rule.when); - if (!whenAst) return null; - return { - command: rule.command, - shortcut, - whenAst, - }; - } - - return { - command: rule.command, - shortcut, - }; -} - -export function compileResolvedKeybindingsConfig( - config: KeybindingsConfig, -): ResolvedKeybindingsConfig { - const compiled: Mutable = []; - for (const rule of config) { - const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule); - if (result._tag === "Success") { - compiled.push(result.value); - } - } - return compiled; -} +import { ServerConfig } from "./config.ts"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; +import { + DEFAULT_KEYBINDINGS, + DEFAULT_RESOLVED_KEYBINDINGS, + compileResolvedKeybindingRule, + compileResolvedKeybindingsConfig, + parseKeybindingShortcut, +} from "@t3tools/shared/keybindings"; + +export { + DEFAULT_KEYBINDINGS, + compileResolvedKeybindingRule, + compileResolvedKeybindingsConfig, + parseKeybindingShortcut, +}; export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( Schema.decodeTo( @@ -378,6 +123,25 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo return leftContext === rightContext; } +function keybindingRuleFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule { + return input.when === undefined + ? { key: input.key, command: input.command } + : { key: input.key, command: input.command, when: input.when }; +} + +function replaceTargetFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule | null { + if (!input.replace) return null; + return input.replace.when === undefined + ? { key: input.replace.key, command: input.replace.command } + : { key: input.replace.key, command: input.replace.command, when: input.replace.when }; +} + +function keybindingRuleFromRemoveInput(input: ServerRemoveKeybindingInput): KeybindingRule { + return input.when === undefined + ? { key: input.key, command: input.command } + : { key: input.key, command: input.command, when: input.when }; +} + function encodeShortcut(shortcut: KeybindingShortcut): string | null { const modifiers: string[] = []; if (shortcut.modKey) modifiers.push("mod"); @@ -404,19 +168,12 @@ function encodeWhenAst(node: KeybindingWhenNode): string { } } -const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); - const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); -const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); -const PrettyJsonString = SchemaGetter.parseJson().compose( - SchemaGetter.stringifyJson({ space: 2 }), -); -const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe( - Schema.encode({ - decode: PrettyJsonString, - encode: PrettyJsonString, - }), -); +const KeybindingsConfigPrettyJson = fromJsonStringPretty(KeybindingsConfig); +const decodeKeybindingRuleExit = Schema.decodeUnknownExit(KeybindingRule); +const decodeResolvedKeybindingFromConfigExit = Schema.decodeExit(ResolvedKeybindingFromConfig); +const decodeRawKeybindingsEntriesExit = Schema.decodeUnknownExit(RawKeybindingsEntries); +const encodeKeybindingsConfigPrettyJson = Schema.encodeEffect(KeybindingsConfigPrettyJson); export interface KeybindingsConfigState { readonly keybindings: ResolvedKeybindingsConfig; @@ -515,7 +272,14 @@ export interface KeybindingsShape { * oldest entries when needed. */ readonly upsertKeybindingRule: ( - rule: KeybindingRule, + input: ServerUpsertKeybindingInput, + ) => Effect.Effect; + + /** + * Remove a single persisted keybinding rule by exact key/command/when match. + */ + readonly removeKeybindingRule: ( + input: ServerRemoveKeybindingInput, ) => Effect.Effect; } @@ -584,7 +348,7 @@ const makeKeybindings = Effect.gen(function* () { return yield* Effect.forEach(rawConfig, (entry) => Effect.gen(function* () { - const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry); + const decodedRule = decodeKeybindingRuleExit(entry); if (decodedRule._tag === "Failure") { yield* Effect.logWarning("ignoring invalid keybinding entry", { path: keybindingsConfigPath, @@ -593,7 +357,7 @@ const makeKeybindings = Effect.gen(function* () { }); return null; } - const resolved = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value); + const resolved = decodeResolvedKeybindingFromConfigExit(decodedRule.value); if (resolved._tag === "Failure") { yield* Effect.logWarning("ignoring invalid keybinding entry", { path: keybindingsConfigPath, @@ -619,7 +383,7 @@ const makeKeybindings = Effect.gen(function* () { } const rawConfig = yield* readRawConfig; - const decodedEntries = Schema.decodeUnknownExit(RawKeybindingsEntries)(rawConfig); + const decodedEntries = decodeRawKeybindingsEntriesExit(rawConfig); if (decodedEntries._tag === "Failure") { const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`; return { @@ -631,7 +395,7 @@ const makeKeybindings = Effect.gen(function* () { const keybindings: KeybindingRule[] = []; const issues: ServerConfigIssue[] = []; for (const [index, entry] of decodedEntries.value.entries()) { - const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry); + const decodedRule = decodeKeybindingRuleExit(entry); if (decodedRule._tag === "Failure") { const detail = Cause.pretty(decodedRule.cause); issues.push(invalidEntryIssue(index, detail)); @@ -644,7 +408,7 @@ const makeKeybindings = Effect.gen(function* () { continue; } - const resolvedRule = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value); + const resolvedRule = decodeResolvedKeybindingFromConfigExit(decodedRule.value); if (resolvedRule._tag === "Failure") { const detail = Cause.pretty(resolvedRule.cause); issues.push(invalidEntryIssue(index, detail)); @@ -663,14 +427,17 @@ const makeKeybindings = Effect.gen(function* () { }); const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { - const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; - - return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( + return encodeKeybindingsConfigPrettyJson(rules).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + Effect.flatMap((encoded) => + writeFileStringAtomically({ + filePath: keybindingsConfigPath, + contents: encoded, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + ), Effect.mapError( (cause) => new KeybindingsConfigError({ @@ -869,12 +636,19 @@ const makeKeybindings = Effect.gen(function* () { get streamChanges() { return Stream.fromPubSub(changesPubSub); }, - upsertKeybindingRule: (rule) => + upsertKeybindingRule: (input) => upsertSemaphore.withPermits(1)( Effect.gen(function* () { const customConfig = yield* loadWritableCustomKeybindingsConfig(); + const rule = keybindingRuleFromUpsertInput(input); + const replaceTarget = replaceTargetFromUpsertInput(input); const nextConfig = [ - ...customConfig.filter((entry) => entry.command !== rule.command), + ...customConfig.filter((entry) => { + if (replaceTarget) { + return !isSameKeybindingRule(entry, replaceTarget); + } + return !isSameKeybindingRule(entry, rule); + }), rule, ]; const cappedConfig = @@ -902,6 +676,27 @@ const makeKeybindings = Effect.gen(function* () { return nextResolved; }), ), + removeKeybindingRule: (input) => + upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + const customConfig = yield* loadWritableCustomKeybindingsConfig(); + const target = keybindingRuleFromRemoveInput(input); + const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target)); + yield* writeConfigAtomically(nextConfig); + const nextResolved = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig(nextConfig), + ); + yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { + keybindings: nextResolved, + issues: [], + }); + yield* emitChange({ + keybindings: nextResolved, + issues: [], + }); + return nextResolved; + }), + ), } satisfies KeybindingsShape; }); diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts index 4b495598ea3..d9ed2e1271f 100644 --- a/apps/server/src/observability/Attributes.test.ts +++ b/apps/server/src/observability/Attributes.test.ts @@ -1,43 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import { compactTraceAttributes, normalizeModelMetricLabel } from "./Attributes.ts"; +import { normalizeModelMetricLabel } from "./Attributes.ts"; describe("Attributes", () => { - it("normalizes circular arrays, maps, and sets without recursing forever", () => { - const array: Array = ["alpha"]; - array.push(array); - - const map = new Map(); - map.set("self", map); - - const set = new Set(); - set.add(set); - - assert.deepStrictEqual( - compactTraceAttributes({ - array, - map, - set, - }), - { - array: ["alpha", "[Circular]"], - map: { self: "[Circular]" }, - set: ["[Circular]"], - }, - ); - }); - - it("normalizes invalid dates without throwing", () => { - assert.deepStrictEqual( - compactTraceAttributes({ - invalidDate: new Date("not-a-real-date"), - }), - { - invalidDate: "Invalid Date", - }, - ); - }); - it("groups GPT-family models under a shared metric label", () => { assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); diff --git a/apps/server/src/observability/Attributes.ts b/apps/server/src/observability/Attributes.ts index 2251fcfea69..168fad18897 100644 --- a/apps/server/src/observability/Attributes.ts +++ b/apps/server/src/observability/Attributes.ts @@ -1,89 +1,10 @@ -import { Cause, Exit } from "effect"; +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; export type MetricAttributeValue = string; export type MetricAttributes = Readonly>; -export type TraceAttributes = Readonly>; export type ObservabilityOutcome = "success" | "failure" | "interrupt"; -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function markSeen(value: object, seen: WeakSet): boolean { - if (seen.has(value)) { - return true; - } - seen.add(value); - return false; -} - -function normalizeJsonValue(value: unknown, seen: WeakSet = new WeakSet()): unknown { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - return value ?? null; - } - if (typeof value === "bigint") { - return value.toString(); - } - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); - } - if (value instanceof Error) { - return { - name: value.name, - message: value.message, - ...(value.stack ? { stack: value.stack } : {}), - }; - } - if (Array.isArray(value)) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return value.map((entry) => normalizeJsonValue(entry, seen)); - } - if (value instanceof Map) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Object.fromEntries( - Array.from(value.entries(), ([key, entryValue]) => [ - String(key), - normalizeJsonValue(entryValue, seen), - ]), - ); - } - if (value instanceof Set) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); - } - if (!isPlainObject(value)) { - return String(value); - } - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), - ); -} - -export function compactTraceAttributes( - attributes: Readonly>, -): TraceAttributes { - return Object.fromEntries( - Object.entries(attributes) - .filter(([, value]) => value !== undefined) - .map(([key, value]) => [key, normalizeJsonValue(value)]), - ); -} - export function compactMetricAttributes( attributes: Readonly>, ): MetricAttributes { diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 7c6a28005f4..94368a45296 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,11 +1,13 @@ -import { Effect, Layer, References, Tracer } from "effect"; +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as References from "effect/References"; +import * as Tracer from "effect/Tracer"; import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { ServerConfig } from "../../config.ts"; import { ServerLoggerLive } from "../../serverLogger.ts"; -import { makeLocalFileTracer } from "../LocalFileTracer.ts"; import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; -import { makeTraceSink } from "../TraceSink.ts"; const otlpSerializationLayer = OtlpSerialization.layerJson; diff --git a/apps/server/src/observability/LocalFileTracer.test.ts b/apps/server/src/observability/LocalFileTracer.test.ts deleted file mode 100644 index 19efffaf10b..00000000000 --- a/apps/server/src/observability/LocalFileTracer.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Logger, References, Tracer } from "effect"; - -import type { EffectTraceRecord } from "./TraceRecord.ts"; -import { makeLocalFileTracer } from "./LocalFileTracer.ts"; - -const makeTestLayer = (tracePath: string) => - Layer.mergeAll( - Layer.effect( - Tracer.Tracer, - makeLocalFileTracer({ - filePath: tracePath, - maxBytes: 1024 * 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }), - ), - Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Info"), - ); - -const readTraceRecords = (tracePath: string): Array => - fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as EffectTraceRecord); - -describe("LocalFileTracer", () => { - it.effect("writes nested spans to disk and captures log messages as span events", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - yield* Effect.scoped( - Effect.gen(function* () { - const program = Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "demo.parent": true, - }); - yield* Effect.logInfo("parent event"); - yield* Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "demo.child": true, - }); - yield* Effect.logInfo("child event"); - }).pipe(Effect.withSpan("child-span")); - }).pipe(Effect.withSpan("parent-span")); - - yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); - }), - ); - - const records = readTraceRecords(tracePath); - assert.equal(records.length, 2); - - const parent = records.find((record) => record.name === "parent-span"); - const child = records.find((record) => record.name === "child-span"); - - assert.notEqual(parent, undefined); - assert.notEqual(child, undefined); - if (!parent || !child) { - return; - } - - assert.equal(child.parentSpanId, parent.spanId); - assert.equal(parent.attributes["demo.parent"], true); - assert.equal(child.attributes["demo.child"], true); - assert.equal( - parent.events.some((event) => event.name === "parent event"), - true, - ); - assert.equal( - child.events.some((event) => event.name === "child event"), - true, - ); - assert.equal( - child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), - true, - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("serializes interrupted spans with an interrupted exit status", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - yield* Effect.scoped( - Effect.exit( - Effect.interrupt.pipe( - Effect.withSpan("interrupt-span"), - Effect.provide(makeTestLayer(tracePath)), - ), - ), - ); - - const records = readTraceRecords(tracePath); - assert.equal(records.length, 1); - assert.equal(records[0]?.name, "interrupt-span"); - assert.equal(records[0]?.exit._tag, "Interrupted"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); -}); diff --git a/apps/server/src/observability/LocalFileTracer.ts b/apps/server/src/observability/LocalFileTracer.ts deleted file mode 100644 index cde5a176e88..00000000000 --- a/apps/server/src/observability/LocalFileTracer.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type * as Exit from "effect/Exit"; -import { Effect, Option, Tracer } from "effect"; - -import { EffectTraceRecord, spanToTraceRecord } from "./TraceRecord.ts"; -import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; - -export interface LocalFileTracerOptions { - readonly filePath: string; - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; - readonly delegate?: Tracer.Tracer; - readonly sink?: TraceSink; -} - -class LocalFileSpan implements Tracer.Span { - readonly _tag = "Span"; - readonly name: string; - readonly spanId: string; - readonly traceId: string; - readonly parent: Option.Option; - readonly annotations: Tracer.Span["annotations"]; - readonly links: Array; - readonly sampled: boolean; - readonly kind: Tracer.SpanKind; - - status: Tracer.SpanStatus; - attributes: Map; - events: Array<[name: string, startTime: bigint, attributes: Record]>; - - constructor( - options: Parameters[0], - private readonly delegate: Tracer.Span, - private readonly push: (record: EffectTraceRecord) => void, - ) { - this.name = delegate.name; - this.spanId = delegate.spanId; - this.traceId = delegate.traceId; - this.parent = options.parent; - this.annotations = options.annotations; - this.links = [...options.links]; - this.sampled = delegate.sampled; - this.kind = delegate.kind; - this.status = { - _tag: "Started", - startTime: options.startTime, - }; - this.attributes = new Map(); - this.events = []; - } - - end(endTime: bigint, exit: Exit.Exit): void { - this.status = { - _tag: "Ended", - startTime: this.status.startTime, - endTime, - exit, - }; - this.delegate.end(endTime, exit); - - if (this.sampled) { - this.push(spanToTraceRecord(this)); - } - } - - attribute(key: string, value: unknown): void { - this.attributes.set(key, value); - this.delegate.attribute(key, value); - } - - event(name: string, startTime: bigint, attributes?: Record): void { - const nextAttributes = attributes ?? {}; - this.events.push([name, startTime, nextAttributes]); - this.delegate.event(name, startTime, nextAttributes); - } - - addLinks(links: ReadonlyArray): void { - this.links.push(...links); - this.delegate.addLinks(links); - } -} - -export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( - options: LocalFileTracerOptions, -) { - const sink = - options.sink ?? - (yield* makeTraceSink({ - filePath: options.filePath, - maxBytes: options.maxBytes, - maxFiles: options.maxFiles, - batchWindowMs: options.batchWindowMs, - })); - - const delegate = - options.delegate ?? - Tracer.make({ - span: (spanOptions) => new Tracer.NativeSpan(spanOptions), - }); - - return Tracer.make({ - span(spanOptions) { - return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); - }, - ...(delegate.context ? { context: delegate.context } : {}), - }); -}); diff --git a/apps/server/src/observability/Metrics.test.ts b/apps/server/src/observability/Metrics.test.ts index 4604f43b63a..3fe6b9a8cd8 100644 --- a/apps/server/src/observability/Metrics.test.ts +++ b/apps/server/src/observability/Metrics.test.ts @@ -1,5 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; -import { Effect, Metric } from "effect"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Metric from "effect/Metric"; +import * as TestClock from "effect/testing/TestClock"; import { withMetrics } from "./Metrics.ts"; @@ -14,6 +19,18 @@ const hasMetricSnapshot = ( Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), ); +const findHistogramSnapshot = ( + snapshots: ReadonlyArray, + id: string, + attributes: Readonly>, +) => + snapshots.find( + (snapshot): snapshot is Extract => + snapshot.type === "Histogram" && + snapshot.id === id && + Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), + ); + describe("withMetrics", () => { it.effect("supports pipe-style usage", () => Effect.gen(function* () { @@ -75,10 +92,11 @@ describe("withMetrics", () => { Effect.gen(function* () { const counter = Metric.counter("with_metrics_lazy_total"); const timer = Metric.timer("with_metrics_lazy_duration"); - let provider = "unknown"; + let provider = ProviderDriverKind.make("unknown"); + const lazyInittedProvider = ProviderDriverKind.make("codex"); yield* Effect.sync(() => { - provider = "codex"; + provider = lazyInittedProvider; }).pipe( withMetrics({ counter, @@ -93,7 +111,7 @@ describe("withMetrics", () => { const snapshots = yield* Metric.snapshot; assert.equal( hasMetricSnapshot(snapshots, "with_metrics_lazy_total", { - provider: "codex", + provider: lazyInittedProvider, operation: "lazy", outcome: "success", }), @@ -101,11 +119,42 @@ describe("withMetrics", () => { ); assert.equal( hasMetricSnapshot(snapshots, "with_metrics_lazy_duration", { - provider: "codex", + provider: lazyInittedProvider, operation: "lazy", }), true, ); }), ); + + it.effect("records timer durations from nanosecond clock readings", () => + Effect.gen(function* () { + const duration = Duration.nanos(1_500_000n); + const timer = Metric.timer("with_metrics_nanos_duration"); + + yield* Effect.gen(function* () { + const fiber = yield* Effect.sleep(duration).pipe( + withMetrics({ + timer, + attributes: { + operation: "nanos", + }, + }), + Effect.forkChild, + ); + + yield* Effect.yieldNow; + yield* TestClock.adjust(duration); + yield* Fiber.join(fiber); + }).pipe(Effect.provide(TestClock.layer())); + + const snapshots = yield* Metric.snapshot; + const snapshot = findHistogramSnapshot(snapshots, "with_metrics_nanos_duration", { + operation: "nanos", + }); + + assert.equal(snapshot?.state.count, 1); + assert.equal(snapshot?.state.sum, 1.5); + }), + ); }); diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 3e527c7cb45..886833d6e2c 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -1,4 +1,8 @@ -import { Duration, Effect, Exit, Metric } from "effect"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Metric from "effect/Metric"; import { dual } from "effect/Function"; import { @@ -96,9 +100,11 @@ const withMetricsImpl = ( options: WithMetricsOptions, ): Effect.Effect => Effect.gen(function* () { - const startedAt = Date.now(); + const startedAt = yield* Clock.currentTimeNanos; const exit = yield* Effect.exit(effect); - const duration = Duration.millis(Math.max(0, Date.now() - startedAt)); + const endedAt = yield* Clock.currentTimeNanos; + const elapsedNanos = endedAt > startedAt ? endedAt - startedAt : 0n; + const duration = Duration.nanos(elapsedNanos); const baseAttributes = typeof options.attributes === "function" ? options.attributes() : (options.attributes ?? {}); diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts index d29b05f3c2b..c7fb7d12b09 100644 --- a/apps/server/src/observability/RpcInstrumentation.test.ts +++ b/apps/server/src/observability/RpcInstrumentation.test.ts @@ -1,5 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; -import { Effect, Exit, Metric, Stream } from "effect"; +import { WS_METHODS } from "@t3tools/contracts"; +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 Metric from "effect/Metric"; +import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; import { observeRpcEffect, @@ -18,6 +26,44 @@ const hasMetricSnapshot = ( Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), ); +const findHistogramSnapshot = ( + snapshots: ReadonlyArray, + id: string, + attributes: Readonly>, +) => + snapshots.find( + (snapshot): snapshot is Extract => + snapshot.type === "Histogram" && + snapshot.id === id && + Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), + ); + +const collectSpanNames = ( + effect: Effect.Effect, +): Effect.Effect, E, R> => + Effect.gen(function* () { + const spanNames: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + + span.end = (endTime, exit) => { + end(endTime, exit); + if (span.sampled) { + spanNames.push(span.name); + } + }; + + return span; + }, + }); + + yield* effect.pipe(Effect.withTracer(tracer)); + + return spanNames; + }); + describe("RpcInstrumentation", () => { it.effect("records success metrics for unary RPC handlers", () => Effect.gen(function* () { @@ -129,6 +175,37 @@ describe("RpcInstrumentation", () => { }), ); + it.effect("records direct stream durations from nanosecond clock readings", () => + Effect.gen(function* () { + const duration = Duration.nanos(1_500_000n); + const events = yield* Effect.gen(function* () { + const fiber = yield* Stream.runCollect( + observeRpcStream( + WS_METHODS.serverGetProcessDiagnostics, + Stream.fromEffect(Effect.sleep(duration).pipe(Effect.as("ok"))), + { + "rpc.aggregate": "test", + }, + ), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust(duration); + return yield* Fiber.join(fiber); + }).pipe(Effect.provide(TestClock.layer())); + + assert.deepStrictEqual(Array.from(events), ["ok"]); + + const snapshots = yield* Metric.snapshot; + const snapshot = findHistogramSnapshot(snapshots, "t3_rpc_request_duration", { + method: WS_METHODS.serverGetProcessDiagnostics, + }); + + assert.equal(snapshot?.state.count, 1); + assert.equal(snapshot?.state.sum, 1.5); + }), + ); + it.effect("records failure outcomes when a stream RPC effect produces a failing stream", () => Effect.gen(function* () { const exit = yield* Stream.runCollect( @@ -158,4 +235,79 @@ describe("RpcInstrumentation", () => { ); }), ); + + it.effect("records spans for traced stream RPC handlers", () => + Effect.gen(function* () { + const spanNames = yield* collectSpanNames( + Stream.runCollect( + observeRpcStream( + "rpc.instrumentation.traced.stream", + Stream.fromEffect( + Effect.succeed("ok").pipe(Effect.withSpan("rpc.instrumentation.traced.stream.child")), + ), + { "rpc.aggregate": "test" }, + ), + ), + ); + + assert.equal(spanNames.includes("ws.rpc.rpc.instrumentation.traced.stream"), true); + assert.equal(spanNames.includes("rpc.instrumentation.traced.stream.child"), true); + }), + ); + + it.effect("does not create spans for disabled unary RPC handlers", () => + Effect.gen(function* () { + const spanNames = yield* collectSpanNames( + observeRpcEffect( + WS_METHODS.serverGetTraceDiagnostics, + Effect.succeed("ok").pipe(Effect.withSpan("rpc.instrumentation.disabled.unary.child")), + { "rpc.aggregate": "test" }, + ), + ); + + assert.deepStrictEqual(spanNames, []); + }), + ); + + it.effect("does not create spans for disabled direct stream RPC handlers", () => + Effect.gen(function* () { + const spanNames = yield* collectSpanNames( + Stream.runCollect( + observeRpcStream( + WS_METHODS.serverGetTraceDiagnostics, + Stream.fromEffect( + Effect.succeed("ok").pipe( + Effect.withSpan("rpc.instrumentation.disabled.stream.child"), + ), + ), + { "rpc.aggregate": "test" }, + ), + ), + ); + + assert.deepStrictEqual(spanNames, []); + }), + ); + + it.effect("does not create spans for disabled stream effect RPC handlers", () => + Effect.gen(function* () { + const spanNames = yield* collectSpanNames( + Stream.runCollect( + observeRpcStreamEffect( + WS_METHODS.serverGetTraceDiagnostics, + Effect.succeed( + Stream.fromEffect( + Effect.succeed("ok").pipe( + Effect.withSpan("rpc.instrumentation.disabled.stream.effect.consume"), + ), + ), + ).pipe(Effect.withSpan("rpc.instrumentation.disabled.stream.effect.create")), + { "rpc.aggregate": "test" }, + ), + ), + ); + + assert.deepStrictEqual(spanNames, []); + }), + ); }); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index a3ac29aa02d..edbd705b3ee 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -1,26 +1,78 @@ -import { Duration, Effect, Exit, Metric, Stream } from "effect"; +import { WS_METHODS } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Metric from "effect/Metric"; +import * as References from "effect/References"; +import * as Stream from "effect/Stream"; import { outcomeFromExit } from "./Attributes.ts"; import { metricAttributes, rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts"; -const annotateRpcSpan = ( +const RPC_SPAN_PREFIX = "ws.rpc"; +const DEFAULT_RPC_SPAN_ATTRIBUTES = { + "rpc.transport": "websocket", + "rpc.system": "effect-rpc", +} as const; +const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet = new Set([ + WS_METHODS.serverGetTraceDiagnostics, + WS_METHODS.serverGetProcessDiagnostics, + WS_METHODS.serverGetProcessResourceHistory, + WS_METHODS.serverSignalProcess, +]); + +function shouldTraceRpc(method: string): boolean { + return !RPC_METHODS_WITH_TRACING_DISABLED.has(method); +} + +const rpcSpanAttributes = ( method: string, traceAttributes?: Readonly>, -): Effect.Effect => - Effect.annotateCurrentSpan({ - "rpc.method": method, - ...traceAttributes, - }); +): Record => ({ + ...DEFAULT_RPC_SPAN_ATTRIBUTES, + "rpc.method": method, + ...traceAttributes, +}); + +const withRpcEffectTracing = ( + method: string, + effect: Effect.Effect, + traceAttributes?: Readonly>, +): Effect.Effect => + shouldTraceRpc(method) + ? effect.pipe( + Effect.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { + attributes: rpcSpanAttributes(method, traceAttributes), + }), + ) + : effect.pipe(Effect.provideService(References.TracerEnabled, false)); + +const withRpcStreamTracing = ( + method: string, + stream: Stream.Stream, + traceAttributes?: Readonly>, +): Stream.Stream => + shouldTraceRpc(method) + ? stream.pipe( + Stream.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { + attributes: rpcSpanAttributes(method, traceAttributes), + }), + ) + : stream.pipe(Stream.provideService(References.TracerEnabled, false)); const recordRpcStreamMetrics = ( method: string, - startedAt: number, + startedAt: bigint, exit: Exit.Exit, ): Effect.Effect => Effect.gen(function* () { + const endedAt = yield* Clock.currentTimeNanos; + const elapsedNanos = endedAt > startedAt ? endedAt - startedAt : 0n; + yield* Metric.update( Metric.withAttributes(rpcRequestDuration, metricAttributes({ method })), - Duration.millis(Math.max(0, Date.now() - startedAt)), + Duration.nanos(elapsedNanos), ); yield* Metric.update( Metric.withAttributes( @@ -38,43 +90,43 @@ export const observeRpcEffect = ( method: string, effect: Effect.Effect, traceAttributes?: Readonly>, -): Effect.Effect => - Effect.gen(function* () { - yield* annotateRpcSpan(method, traceAttributes); +): Effect.Effect => { + const instrumented = effect.pipe( + withMetrics({ + counter: rpcRequestsTotal, + timer: rpcRequestDuration, + attributes: { + method, + }, + }), + ); - return yield* effect.pipe( - withMetrics({ - counter: rpcRequestsTotal, - timer: rpcRequestDuration, - attributes: { - method, - }, - }), - ); - }); + return withRpcEffectTracing(method, instrumented, traceAttributes); +}; export const observeRpcStream = ( method: string, stream: Stream.Stream, traceAttributes?: Readonly>, -): Stream.Stream => - Stream.unwrap( +): Stream.Stream => { + const instrumented = Stream.unwrap( Effect.gen(function* () { - yield* annotateRpcSpan(method, traceAttributes); - const startedAt = Date.now(); + const startedAt = yield* Clock.currentTimeNanos; return stream.pipe(Stream.onExit((exit) => recordRpcStreamMetrics(method, startedAt, exit))); }), ); + return withRpcStreamTracing(method, instrumented, traceAttributes); +}; + export const observeRpcStreamEffect = ( method: string, effect: Effect.Effect, EffectError, EffectContext>, traceAttributes?: Readonly>, -): Stream.Stream => - Stream.unwrap( +): Stream.Stream => { + const instrumented = Stream.unwrap( Effect.gen(function* () { - yield* annotateRpcSpan(method, traceAttributes); - const startedAt = Date.now(); + const startedAt = yield* Clock.currentTimeNanos; const exit = yield* Effect.exit(effect); if (Exit.isFailure(exit)) { @@ -87,3 +139,6 @@ export const observeRpcStreamEffect = ) => Effect.Effect; diff --git a/apps/server/src/observability/TraceSink.test.ts b/apps/server/src/observability/TraceSink.test.ts deleted file mode 100644 index f4db90516b1..00000000000 --- a/apps/server/src/observability/TraceSink.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import type { TraceRecord } from "./TraceRecord.ts"; -import { makeTraceSink } from "./TraceSink.ts"; - -const makeRecord = (name: string, suffix = ""): TraceRecord => ({ - type: "effect-span", - name, - traceId: `trace-${name}-${suffix}`, - spanId: `span-${name}-${suffix}`, - sampled: true, - kind: "internal", - startTimeUnixNano: "1", - endTimeUnixNano: "2", - durationMs: 1, - attributes: { - payload: suffix, - }, - events: [], - links: [], - exit: { - _tag: "Success", - }, -}); - -describe("TraceSink", () => { - it.effect("flushes buffered records on close", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - sink.push(makeRecord("alpha")); - sink.push(makeRecord("beta")); - yield* sink.close(); - - const lines = fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as TraceRecord); - - assert.equal(lines.length, 2); - assert.equal(lines[0]?.name, "alpha"); - assert.equal(lines[1]?.name, "beta"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); - - it.effect("rotates the trace file when the configured max size is exceeded", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 180, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - for (let index = 0; index < 8; index += 1) { - sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); - yield* sink.flush; - } - yield* sink.close(); - - const matchingFiles = fs - .readdirSync(tempDir) - .filter( - (entry) => - entry === "server.trace.ndjson" || entry.startsWith("server.trace.ndjson."), - ) - .toSorted(); - - assert.equal( - matchingFiles.some((entry) => entry === "server.trace.ndjson.1"), - true, - ); - assert.equal( - matchingFiles.some((entry) => entry === "server.trace.ndjson.3"), - false, - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); - - it.effect("drops only the invalid record when serialization fails", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - const circular: Array = []; - circular.push(circular); - - sink.push(makeRecord("alpha")); - sink.push({ - ...makeRecord("invalid"), - attributes: { - circular, - }, - } as TraceRecord); - sink.push(makeRecord("beta")); - yield* sink.close(); - - const lines = fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as TraceRecord); - - assert.deepStrictEqual( - lines.map((line) => line.name), - ["alpha", "beta"], - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); -}); diff --git a/apps/server/src/observability/TraceSink.ts b/apps/server/src/observability/TraceSink.ts deleted file mode 100644 index 1bd00b47341..00000000000 --- a/apps/server/src/observability/TraceSink.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { Effect } from "effect"; - -import type { TraceRecord } from "./TraceRecord.ts"; - -const FLUSH_BUFFER_THRESHOLD = 32; - -export interface TraceSinkOptions { - readonly filePath: string; - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; -} - -export interface TraceSink { - readonly filePath: string; - push: (record: TraceRecord) => void; - flush: Effect.Effect; - close: () => Effect.Effect; -} - -export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { - const sink = new RotatingFileSink({ - filePath: options.filePath, - maxBytes: options.maxBytes, - maxFiles: options.maxFiles, - }); - - let buffer: Array = []; - - const flushUnsafe = () => { - if (buffer.length === 0) { - return; - } - - const chunk = buffer.join(""); - buffer = []; - - try { - sink.write(chunk); - } catch { - buffer.unshift(chunk); - } - }; - - const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); - - yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); - yield* Effect.forkScoped( - Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), - ); - - return { - filePath: options.filePath, - push(record) { - try { - buffer.push(`${JSON.stringify(record)}\n`); - if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { - flushUnsafe(); - } - } catch { - return; - } - }, - flush, - close: () => flush, - } satisfies TraceSink; -}); diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts deleted file mode 100644 index 59b0239c960..00000000000 --- a/apps/server/src/open.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { assertSuccess } from "@effect/vitest/utils"; -import { FileSystem, Path, Effect } from "effect"; - -import { - isCommandAvailable, - launchDetached, - resolveAvailableEditors, - resolveEditorLaunch, -} from "./open"; - -it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { - it.effect("returns commands for command-based editors", () => - Effect.gen(function* () { - const antigravityLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "antigravity" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(antigravityLaunch, { - command: "agy", - args: ["/tmp/workspace"], - }); - - const cursorLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(cursorLaunch, { - command: "cursor", - args: ["/tmp/workspace"], - }); - - const traeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "trae" }, - "darwin", - ); - assert.deepEqual(traeLaunch, { - command: "trae", - args: ["/tmp/workspace"], - }); - - const vscodeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(vscodeLaunch, { - command: "code", - args: ["/tmp/workspace"], - }); - - const vscodeInsidersLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLaunch, { - command: "code-insiders", - args: ["/tmp/workspace"], - }); - - const vscodiumLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLaunch, { - command: "codium", - args: ["/tmp/workspace"], - }); - - const zedLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLaunch, { - command: "zed", - args: ["/tmp/workspace"], - }); - - const ideaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLaunch, { - command: "idea", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("applies launch-style-specific navigation arguments", () => - Effect.gen(function* () { - const lineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(lineOnly, { - command: "cursor", - args: ["--goto", "/tmp/workspace/AGENTS.md:48"], - }); - - const lineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(lineAndColumn, { - command: "cursor", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); - - const traeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "trae" }, - "darwin", - ); - assert.deepEqual(traeLineAndColumn, { - command: "trae", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); - - const vscodeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(vscodeLineAndColumn, { - command: "code", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); - - const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLineAndColumn, { - command: "code-insiders", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); - - const vscodiumLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLineAndColumn, { - command: "codium", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); - - const zedLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLineAndColumn, { - command: "zed", - args: ["/tmp/workspace/src/open.ts:71:5"], - }); - - const zedLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLineOnly, { - command: "zed", - args: ["/tmp/workspace/AGENTS.md:48"], - }); - - const ideaLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLineOnly, { - command: "idea", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - - const ideaLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLineAndColumn, { - command: "idea", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], - }); - }), - ); - - it.effect("falls back to zeditor when zed is not installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: dir, - }); - - assert.deepEqual(result, { - command: "zeditor", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("falls back to the primary command when no alias is installed", () => - Effect.gen(function* () { - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: "", - }); - assert.deepEqual(result, { - command: "zed", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("maps file-manager editor to OS open commands", () => - Effect.gen(function* () { - const launch1 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(launch1, { - command: "open", - args: ["/tmp/workspace"], - }); - - const launch2 = yield* resolveEditorLaunch( - { cwd: "C:\\workspace", editor: "file-manager" }, - "win32", - { PATH: "" }, - ); - assert.deepEqual(launch2, { - command: "explorer", - args: ["C:\\workspace"], - }); - - const launch3 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "linux", - { PATH: "" }, - ); - assert.deepEqual(launch3, { - command: "xdg-open", - args: ["/tmp/workspace"], - }); - }), - ); -}); - -it.layer(NodeServices.layer)("launchDetached", (it) => { - it.effect("resolves when command can be spawned", () => - Effect.gen(function* () { - const result = yield* launchDetached({ - command: process.execPath, - args: ["-e", "process.exit(0)"], - }).pipe(Effect.result); - assertSuccess(result, undefined); - }), - ); - - it.effect("rejects when command does not exist", () => - Effect.gen(function* () { - const result = yield* launchDetached({ - command: `t3code-no-such-command-${Date.now()}`, - args: [], - }).pipe(Effect.result); - assert.equal(result._tag, "Failure"); - }), - ); -}); - -it.layer(NodeServices.layer)("isCommandAvailable", (it) => { - it.effect("resolves win32 commands with PATHEXT", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), - ); - - it("returns false when a command is not on PATH", () => { - const env = { - PATH: "", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); - }); - - it.effect("does not treat bare files without executable extension as available on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); - }), - ); - - it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); - }), - ); - - it.effect("uses platform-specific PATH delimiter for platform overrides", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); - const env = { - PATH: `${firstDir};${secondDir}`, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), - ); -}); - -it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { - it.effect("returns installed editors for command launches", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); - const editors = resolveAvailableEditors("win32", { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }); - assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]); - }), - ); - - it.effect("includes zed when only the zeditor command is installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - - const editors = resolveAvailableEditors("linux", { - PATH: dir, - }); - assert.deepEqual(editors, ["zed", "file-manager"]); - }), - ); - - it("omits file-manager when the platform opener is unavailable", () => { - const editors = resolveAvailableEditors("linux", { - PATH: "", - }); - assert.deepEqual(editors, []); - }); -}); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts deleted file mode 100644 index 870da4e55e6..00000000000 --- a/apps/server/src/open.ts +++ /dev/null @@ -1,340 +0,0 @@ -/** - * Open - Browser/editor launch service interface. - * - * Owns process launch helpers for opening URLs in a browser and workspace - * paths in a configured editor. - * - * @module Open - */ -import { spawn } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; - -import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; -import { Context, Effect, Layer } from "effect"; - -// ============================== -// Definitions -// ============================== - -export { OpenError }; - -export interface OpenInEditorInput { - readonly cwd: string; - readonly editor: EditorId; -} - -interface EditorLaunch { - readonly command: string; - readonly args: ReadonlyArray; -} - -interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; -} - -const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; - -function parseTargetPathAndPosition(target: string): { - path: string; - line: string | undefined; - column: string | undefined; -} | null { - const match = TARGET_WITH_POSITION_PATTERN.exec(target); - if (!match?.[1] || !match[2]) { - return null; - } - - return { - path: match[1], - line: match[2], - column: match[3], - }; -} - -function resolveCommandEditorArgs( - editor: (typeof EDITORS)[number], - target: string, -): ReadonlyArray { - const parsedTarget = parseTargetPathAndPosition(target); - - switch (editor.launchStyle) { - case "direct-path": - return [target]; - case "goto": - return parsedTarget ? ["--goto", target] : [target]; - case "line-column": { - if (!parsedTarget) { - return [target]; - } - - const { path, line, column } = parsedTarget; - return [...(line ? ["--line", line] : []), ...(column ? ["--column", column] : []), path]; - } - } -} - -function resolveAvailableCommand( - commands: ReadonlyArray, - options: CommandAvailabilityOptions = {}, -): string | null { - for (const command of commands) { - if (isCommandAvailable(command, options)) { - return command; - } - } - return null; -} - -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { - switch (platform) { - case "darwin": - return "open"; - case "win32": - return "explorer"; - default: - return "xdg-open"; - } -} - -function stripWrappingQuotes(value: string): string { - return value.replace(/^"+|"+$/g, ""); -} - -function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { - return env.PATH ?? env.Path ?? env.path ?? ""; -} - -function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { - const rawValue = env.PATHEXT; - const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; - if (!rawValue) return fallback; - - const parsed = rawValue - .split(";") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); - return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; -} - -function resolveCommandCandidates( - command: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): ReadonlyArray { - if (platform !== "win32") return [command]; - const extension = extname(command); - const normalizedExtension = extension.toUpperCase(); - - if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { - const commandWithoutExtension = command.slice(0, -extension.length); - return Array.from( - new Set([ - command, - `${commandWithoutExtension}${normalizedExtension}`, - `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, - ]), - ); - } - - const candidates: string[] = []; - for (const extension of windowsPathExtensions) { - candidates.push(`${command}${extension}`); - candidates.push(`${command}${extension.toLowerCase()}`); - } - return Array.from(new Set(candidates)); -} - -function isExecutableFile( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePathDelimiter(platform: NodeJS.Platform): string { - return platform === "win32" ? ";" : ":"; -} - -export function isCommandAvailable( - command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); - } - - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; - const pathEntries = pathValue - .split(resolvePathDelimiter(platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; - } - } - } - return false; -} - -export function resolveAvailableEditors( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { - const available: EditorId[] = []; - - for (const editor of EDITORS) { - if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { - available.push(editor.id); - } - continue; - } - - const command = resolveAvailableCommand(editor.commands, { platform, env }); - if (command !== null) { - available.push(editor.id); - } - } - - return available; -} - -/** - * OpenShape - Service API for browser and editor launch actions. - */ -export interface OpenShape { - /** - * Open a URL target in the default browser. - */ - readonly openBrowser: (target: string) => Effect.Effect; - - /** - * Open a workspace path in a selected editor integration. - * - * Launches the editor as a detached process so server startup is not blocked. - */ - readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect; -} - -/** - * Open - Service tag for browser/editor launch operations. - */ -export class Open extends Context.Service()("t3/open") {} - -// ============================== -// Implementations -// ============================== - -export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( - input: OpenInEditorInput, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { - yield* Effect.annotateCurrentSpan({ - "open.editor": input.editor, - "open.cwd": input.cwd, - "open.platform": platform, - }); - const editorDef = EDITORS.find((editor) => editor.id === input.editor); - if (!editorDef) { - return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); - } - - if (editorDef.commands) { - const command = - resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; - return { - command, - args: resolveCommandEditorArgs(editorDef, input.cwd), - }; - } - - if (editorDef.id !== "file-manager") { - return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); - } - - return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; -}); - -export const launchDetached = (launch: EditorLaunch) => - Effect.gen(function* () { - if (!isCommandAvailable(launch.command)) { - return yield* new OpenError({ message: `Editor command not found: ${launch.command}` }); - } - - yield* Effect.callback((resume) => { - let child; - try { - const isWin32 = process.platform === "win32"; - child = spawn( - launch.command, - isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args], - { - detached: true, - stdio: "ignore", - shell: isWin32, - }, - ); - } catch (error) { - return resume( - Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), - ); - } - - const handleSpawn = () => { - child.unref(); - resume(Effect.void); - }; - - child.once("spawn", handleSpawn); - child.once("error", (cause) => - resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), - ); - }); - }); - -const make = Effect.gen(function* () { - const open = yield* Effect.tryPromise({ - try: () => import("open"), - catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }), - }); - - return { - openBrowser: (target) => - Effect.tryPromise({ - try: () => open.default(target), - catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), - }), - openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), - } satisfies OpenShape; -}); - -export const OpenLive = Layer.effect(Open, make); diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 1ea038e1d13..888fd4b3c0d 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,4 +1,5 @@ -import { SchemaIssue, Schema } from "effect"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as Schema from "effect/Schema"; import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 12e11450dd3..b610c0abc28 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1,9 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderRuntimeEvent, + ProviderSession, + ProviderInstanceId, +} from "@t3tools/contracts"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, @@ -14,13 +20,21 @@ import { TurnId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; -import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -35,6 +49,7 @@ import { type OrchestrationEngineShape, } from "../Services/OrchestrationEngine.ts"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderService, type ProviderServiceShape, @@ -50,7 +65,7 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -64,9 +79,9 @@ function createProviderServiceHarness( cwd: string, hasSession = true, sessionCwd = cwd, - providerName: ProviderSession["provider"] = "codex", + providerName: ProviderSession["provider"] = ProviderDriverKind.make("codex"), ) { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const rollbackConversation = vi.fn( (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void, @@ -97,6 +112,17 @@ function createProviderServiceHarness( stopSession: () => unsupported(), listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => + Effect.succeed({ + instanceId, + driverKind: ProviderDriverKind.make(providerName), + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: ProviderDriverKind.make(providerName), + continuationKey: `${providerName}:instance:${instanceId}`, + }, + }), rollbackConversation, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -115,7 +141,14 @@ function createProviderServiceHarness( } async function waitForThread( - engine: OrchestrationEngineShape, + readModel: () => Promise<{ + readonly threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly latestTurn: { readonly turnId: string } | null; + readonly checkpoints: ReadonlyArray<{ readonly checkpointTurnCount: number }>; + readonly activities: ReadonlyArray<{ readonly kind: string }>; + }>; + }>, predicate: (thread: { latestTurn: { turnId: string } | null; checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; @@ -123,21 +156,21 @@ async function waitForThread( }) => boolean, timeoutMs = 15_000, ) { - const deadline = Date.now() + timeoutMs; + const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; const poll = async (): Promise<{ latestTurn: { turnId: string } | null; checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; }> => { - const readModel = await Effect.runPromise(engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const snapshot = await readModel(); + const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); if (thread && predicate(thread)) { return thread; } - if (Date.now() >= deadline) { + if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) { throw new Error("Timed out waiting for thread state."); } - await new Promise((resolve) => setTimeout(resolve, 10)); + await Effect.runPromise(Effect.sleep("10 millis")); return poll(); }; return poll(); @@ -148,7 +181,7 @@ async function waitForEvent( predicate: (event: { type: string }) => boolean, timeoutMs = 15_000, ) { - const deadline = Date.now() + timeoutMs; + const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; const poll = async () => { const events = await Effect.runPromise( Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), @@ -156,10 +189,10 @@ async function waitForEvent( if (events.some(predicate)) { return events; } - if (Date.now() >= deadline) { + if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) { throw new Error("Timed out waiting for orchestration event."); } - await new Promise((resolve) => setTimeout(resolve, 10)); + await Effect.runPromise(Effect.sleep("10 millis")); return poll(); }; return poll(); @@ -198,15 +231,15 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { } async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) { - const deadline = Date.now() + timeoutMs; + const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; const poll = async (): Promise => { if (gitRefExists(cwd, ref)) { return; } - if (Date.now() >= deadline) { + if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) { throw new Error(`Timed out waiting for git ref '${ref}'.`); } - await new Promise((resolve) => setTimeout(resolve, 10)); + await Effect.runPromise(Effect.sleep("10 millis")); return poll(); }; return poll(); @@ -214,7 +247,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) describe("CheckpointReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | CheckpointReactor | CheckpointStore, + OrchestrationEngineService | CheckpointReactor | CheckpointStore | ProjectionSnapshotQuery, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -243,7 +276,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; - readonly providerName?: ProviderKind; + readonly providerName?: ProviderDriverKind; readonly gitStatusRefreshCalls?: Array; }) { const cwd = createGitRepository(); @@ -252,7 +285,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, - options?.providerName ?? "codex", + options?.providerName ?? ProviderDriverKind.make("codex"), ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -262,11 +295,15 @@ describe("CheckpointReactor", () => { Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); + const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + ); const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-reactor-test-", }); - const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, { + const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: (cwd: string) => Effect.sync(() => { @@ -274,9 +311,9 @@ describe("CheckpointReactor", () => { }).pipe( Effect.as({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -287,26 +324,33 @@ describe("CheckpointReactor", () => { const layer = CheckpointReactorLive.pipe( Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(gitStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive), - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(vcsStatusBroadcasterLayer), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge( + WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(VcsDriverRegistry.layer), + ), + ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( engine.dispatch({ type: "project.create", @@ -315,7 +359,7 @@ describe("CheckpointReactor", () => { title: "Test Project", workspaceRoot: options?.projectWorkspaceRoot ?? cwd, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -329,7 +373,7 @@ describe("CheckpointReactor", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -365,6 +409,7 @@ describe("CheckpointReactor", () => { return { engine, + readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, cwd, drain, @@ -373,7 +418,7 @@ describe("CheckpointReactor", () => { it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -396,9 +441,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), }); @@ -411,9 +456,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), payload: { state: "completed" }, @@ -421,7 +466,7 @@ describe("CheckpointReactor", () => { await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); @@ -457,8 +502,8 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-refresh-local-status"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-refresh-local-status"), payload: { state: "completed" }, @@ -471,7 +516,7 @@ describe("CheckpointReactor", () => { it("ignores auxiliary thread turn completion while primary turn is active", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -494,9 +539,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-main"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-main"), }); @@ -510,32 +555,32 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-aux"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-aux"), payload: { state: "completed" }, }); await harness.drain(); - const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); + const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(midThread?.checkpoints).toHaveLength(0); harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-main"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-main"), payload: { state: "completed" }, }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); @@ -544,9 +589,9 @@ describe("CheckpointReactor", () => { it("captures pre-turn and completion checkpoints for claude runtime events", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, - providerName: "claudeAgent", + providerName: ProviderDriverKind.make("claudeAgent"), }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -569,8 +614,8 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-claude-1"), - provider: "claudeAgent", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-1"), }); @@ -583,8 +628,8 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-claude-1"), - provider: "claudeAgent", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-claude-1"), payload: { state: "completed" }, @@ -592,7 +637,7 @@ describe("CheckpointReactor", () => { await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1, ); @@ -604,7 +649,7 @@ describe("CheckpointReactor", () => { it("appends capture failure activity when turn diff summary cannot be derived", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -627,9 +672,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-missing-baseline"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-missing-baseline"), payload: { state: "completed" }, @@ -637,7 +682,7 @@ describe("CheckpointReactor", () => { await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.checkpoints.length === 1 && entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), @@ -669,7 +714,7 @@ describe("CheckpointReactor", () => { }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", }), ); @@ -692,7 +737,7 @@ describe("CheckpointReactor", () => { seedFilesystemCheckpoints: false, threadWorktreePath: null, }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -716,9 +761,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-missing-cwd"), payload: { state: "completed" }, @@ -739,7 +784,7 @@ describe("CheckpointReactor", () => { it("ignores non-v2 checkpoint.captured runtime events", async () => { const harness = await createHarness(); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -762,9 +807,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "checkpoint.captured", eventId: EventId.make("evt-checkpoint-captured-3"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-3"), turnCount: 3, @@ -772,7 +817,7 @@ describe("CheckpointReactor", () => { }); await harness.drain(); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( false, @@ -789,7 +834,7 @@ describe("CheckpointReactor", () => { seedFilesystemCheckpoints: false, providerSessionCwd: nonRepositorySessionCwd, }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -812,9 +857,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.make("evt-runtime-capture-failure"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-runtime-failure"), payload: { state: "completed" }, @@ -823,9 +868,9 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.make("evt-turn-started-after-runtime-failure"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-after-runtime-failure"), }); @@ -841,7 +886,7 @@ describe("CheckpointReactor", () => { it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { const harness = await createHarness(); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -901,7 +946,10 @@ describe("CheckpointReactor", () => { ); await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); - const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); + const thread = await waitForThread( + harness.readModel, + (entry) => entry.checkpoints.length === 1, + ); expect(thread.latestTurn?.turnId).toBe("turn-1"); expect(thread.checkpoints).toHaveLength(1); @@ -918,8 +966,8 @@ describe("CheckpointReactor", () => { }); it("executes provider revert and emits thread.reverted for claude sessions", async () => { - const harness = await createHarness({ providerName: "claudeAgent" }); - const createdAt = new Date().toISOString(); + const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") }); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -988,7 +1036,7 @@ describe("CheckpointReactor", () => { it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1071,7 +1119,7 @@ describe("CheckpointReactor", () => { it("appends an error activity when revert is requested without an active session", async () => { const harness = await createHarness({ hasSession: false }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1083,7 +1131,7 @@ describe("CheckpointReactor", () => { }), ); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 0b1b203ba24..da135b0c056 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -8,7 +8,12 @@ import { type OrchestrationEvent, type ProviderRuntimeEvent, } from "@t3tools/contracts"; -import { Cause, Effect, Layer, Option, Stream } from "effect"; +import * as Cause from "effect/Cause"; +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 { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; @@ -20,13 +25,16 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; -import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; -import { OrchestrationDispatchError } from "../Errors.ts"; +import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; +import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; -import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + type ReactorInput = | { readonly source: "runtime"; @@ -66,11 +74,12 @@ const serverCommandId = (tag: string): CommandId => const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; const checkpointStore = yield* CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const appendRevertFailureActivity = (input: { readonly threadId: ThreadId; @@ -124,29 +133,26 @@ const make = Effect.gen(function* () { const resolveSessionRuntimeForThread = Effect.fn("resolveSessionRuntimeForThread")(function* ( threadId: ThreadId, ): Effect.fn.Return> { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === threadId); - const sessions = yield* providerService.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + return session?.cwd + ? Option.some({ threadId: session.threadId, cwd: session.cwd }) + : Option.none(); + }); - const findSessionWithCwd = ( - session: (typeof sessions)[number] | undefined, - ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => { - if (!session?.cwd) { - return Option.none(); - } - return Option.some({ threadId: session.threadId, cwd: session.cwd }); - }; - - if (thread) { - const projectedSession = sessions.find((session) => session.threadId === thread.id); - const fromProjected = findSessionWithCwd(projectedSession); - if (Option.isSome(fromProjected)) { - return fromProjected; - } - } + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + return yield* projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(Effect.map(Option.getOrUndefined)); + }); - return Option.none(); + const resolveThreadProjects = Effect.fn("resolveThreadProjects")(function* ( + projectId: ProjectId, + ) { + const project = yield* projectionSnapshotQuery + .getProjectShellById(projectId) + .pipe(Effect.map(Option.getOrUndefined)); + return project ? [project] : []; }); const isGitWorkspace = (cwd: string) => isGitRepository(cwd); @@ -237,6 +243,7 @@ const make = Effect.gen(function* () { fromCheckpointRef, toCheckpointRef: targetCheckpointRef, fallbackFromToHead: false, + ignoreWhitespace: false, }) .pipe( Effect.map((diff) => @@ -330,8 +337,7 @@ const make = Effect.gen(function* () { return; } - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); + const thread = yield* resolveThreadDetail(event.threadId); if (!thread) { return; } @@ -352,10 +358,11 @@ const make = Effect.gen(function* () { return; } + const projects = yield* resolveThreadProjects(thread.projectId); const checkpointCwd = yield* resolveCheckpointCwd({ threadId: thread.id, thread, - projects: readModel.projects, + projects, preferSessionRuntime: true, }); if (!checkpointCwd) { @@ -406,8 +413,7 @@ const make = Effect.gen(function* () { return; } - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === threadId); + const thread = yield* resolveThreadDetail(threadId); if (!thread) { yield* Effect.logWarning("checkpoint capture from placeholder skipped: thread not found", { threadId, @@ -428,10 +434,11 @@ const make = Effect.gen(function* () { return; } + const projects = yield* resolveThreadProjects(thread.projectId); const checkpointCwd = yield* resolveCheckpointCwd({ threadId, thread, - projects: readModel.projects, + projects, preferSessionRuntime: true, }); if (!checkpointCwd) { @@ -457,16 +464,16 @@ const make = Effect.gen(function* () { return; } - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); + const thread = yield* resolveThreadDetail(event.threadId); if (!thread) { return; } + const projects = yield* resolveThreadProjects(thread.projectId); const checkpointCwd = yield* resolveCheckpointCwd({ threadId: thread.id, thread, - projects: readModel.projects, + projects, preferSessionRuntime: false, }); if (!checkpointCwd) { @@ -508,7 +515,7 @@ const make = Effect.gen(function* () { return; } - yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( + yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( Effect.catch((error) => Effect.logWarning("failed to refresh local git status after turn completion", { threadId: event.threadId, @@ -539,16 +546,16 @@ const make = Effect.gen(function* () { } const threadId = event.payload.threadId; - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === threadId); + const thread = yield* resolveThreadDetail(threadId); if (!thread) { return; } + const projects = yield* resolveThreadProjects(thread.projectId); const checkpointCwd = yield* resolveCheckpointCwd({ threadId, thread, - projects: readModel.projects, + projects, preferSessionRuntime: false, }); if (!checkpointCwd) { @@ -584,10 +591,9 @@ const make = Effect.gen(function* () { const handleRevertRequested = Effect.fn("handleRevertRequested")(function* ( event: Extract, ) { - const now = new Date().toISOString(); + const now = DateTime.formatIso(yield* DateTime.now); - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId); + const thread = yield* resolveThreadDetail(event.payload.threadId); if (!thread) { yield* appendRevertFailureActivity({ threadId: event.payload.threadId, @@ -718,12 +724,14 @@ const make = Effect.gen(function* () { if (event.type === "thread.checkpoint-revert-requested") { yield* handleRevertRequested(event).pipe( Effect.catch((error) => - appendRevertFailureActivity({ - threadId: event.payload.threadId, - turnCount: event.payload.turnCount, - detail: error.message, - createdAt: new Date().toISOString(), - }), + Effect.flatMap(nowIso, (createdAt) => + appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: error.message, + createdAt, + }), + ), ), ); return; @@ -737,12 +745,14 @@ const make = Effect.gen(function* () { if (event.type === "thread.turn-diff-completed") { yield* captureCheckpointFromPlaceholder(event).pipe( Effect.catch((error) => - appendCaptureFailureActivity({ - threadId: event.payload.threadId, - turnId: event.payload.turnId, - detail: error.message, - createdAt: new Date().toISOString(), - }).pipe(Effect.catch(() => Effect.void)), + Effect.flatMap(nowIso, (createdAt) => + appendCaptureFailureActivity({ + threadId: event.payload.threadId, + turnId: event.payload.turnId, + detail: error.message, + createdAt, + }).pipe(Effect.catch(() => Effect.void)), + ), ), ); } @@ -761,12 +771,14 @@ const make = Effect.gen(function* () { yield* refreshLocalGitStatusFromTurnCompletion(event); yield* captureCheckpointFromTurnCompletion(event).pipe( Effect.catch((error) => - appendCaptureFailureActivity({ - threadId: event.threadId, - turnId, - detail: error.message, - createdAt: new Date().toISOString(), - }).pipe(Effect.catch(() => Effect.void)), + Effect.flatMap(nowIso, (createdAt) => + appendCaptureFailureActivity({ + threadId: event.threadId, + turnId, + detail: error.message, + createdAt, + }).pipe(Effect.catch(() => Effect.void)), + ), ), ); return; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index eaf66f3971d..7909d5cd6b1 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -7,8 +7,16 @@ import { ThreadId, TurnId, type OrchestrationEvent, + ProviderInstanceId, } from "@t3tools/contracts"; -import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Metric from "effect/Metric"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; import { describe, expect, it } from "vitest"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; @@ -30,7 +38,6 @@ import { } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); @@ -41,9 +48,13 @@ async function createOrchestrationSystem() { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-", }); - const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), - Layer.provide(OrchestrationProjectionPipelineLive), + const orchestrationLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, + ).pipe( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolverLive), @@ -53,15 +64,17 @@ async function createOrchestrationSystem() { ); const runtime = ManagedRuntime.make(orchestrationLayer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { engine, + readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()), run: (effect: Effect.Effect) => runtime.runPromise(effect), dispose: () => runtime.dispose(), }; } function now() { - return new Date().toISOString(); + return "2026-01-01T00:00:00.000Z"; } const hasMetricSnapshot = ( @@ -76,15 +89,18 @@ const hasMetricSnapshot = ( ); describe("OrchestrationEngine", () => { - it("bootstraps the in-memory read model from persisted projections", async () => { - const failOnHistoricalReplayStore: OrchestrationEventStoreShape = { - append: () => - Effect.fail( - new PersistenceSqlError({ - operation: "test.append", - detail: "append should not be called during bootstrap", - }), - ), + it("bootstraps command handling from persisted projections without reading the full snapshot", async () => { + let nextSequence = 8; + const eventStore: OrchestrationEventStoreShape = { + append: (event) => + Effect.sync(() => { + const savedEvent = { + ...event, + sequence: nextSequence, + } as OrchestrationEvent; + nextSequence += 1; + return savedEvent; + }), readFromSequence: () => Stream.empty, readAll: () => Stream.fail( @@ -104,7 +120,7 @@ describe("OrchestrationEngine", () => { title: "Bootstrap Project", workspaceRoot: "/tmp/project-bootstrap", defaultModelSelection: { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [], @@ -119,7 +135,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-bootstrap"), title: "Bootstrap Thread", modelSelection: { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -139,15 +155,51 @@ describe("OrchestrationEngine", () => { }, ], }; + const commandReadModel = { + ...projectionSnapshot, + threads: projectionSnapshot.threads.map((thread) => ({ + ...thread, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + })), + }; + let fullSnapshotReadCount = 0; const layer = OrchestrationEngineLive.pipe( Layer.provide( Layer.succeed(ProjectionSnapshotQuery, { - getSnapshot: () => Effect.succeed(projectionSnapshot), + getCommandReadModel: () => Effect.succeed(commandReadModel), + getSnapshot: () => + Effect.sync(() => { + fullSnapshotReadCount += 1; + return projectionSnapshot; + }), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: projectionSnapshot.snapshotSequence, + projects: [], + threads: [], + updatedAt: projectionSnapshot.updatedAt, + }), + getArchivedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: projectionSnapshot.snapshotSequence, + projects: [], + threads: [], + updatedAt: projectionSnapshot.updatedAt, + }), + getSnapshotSequence: () => + Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), Layer.provide( @@ -156,7 +208,7 @@ describe("OrchestrationEngine", () => { projectEvent: () => Effect.void, } satisfies OrchestrationProjectionPipelineShape), ), - Layer.provide(Layer.succeed(OrchestrationEventStore, failOnHistoricalReplayStore)), + Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), ); @@ -164,18 +216,22 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); - const readModel = await runtime.runPromise(engine.getReadModel()); + const result = await runtime.runPromise( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-bootstrap-thread-update"), + threadId: ThreadId.make("thread-bootstrap"), + title: "Updated Bootstrap Thread", + }), + ); - expect(readModel.snapshotSequence).toBe(7); - expect(readModel.projects).toHaveLength(1); - expect(readModel.projects[0]?.title).toBe("Bootstrap Project"); - expect(readModel.threads).toHaveLength(1); - expect(readModel.threads[0]?.title).toBe("Bootstrap Thread"); + expect(result.sequence).toBe(8); + expect(fullSnapshotReadCount).toBe(0); await runtime.dispose(); }); - it("returns deterministic read models for repeated reads", async () => { + it("persists deterministic read models for repeated snapshot reads", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); const { engine } = system; @@ -188,7 +244,7 @@ describe("OrchestrationEngine", () => { title: "Project 1", workspaceRoot: "/tmp/project-1", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -202,7 +258,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -229,8 +285,8 @@ describe("OrchestrationEngine", () => { }), ); - const readModelA = await system.run(engine.getReadModel()); - const readModelB = await system.run(engine.getReadModel()); + const readModelA = await system.readModel(); + const readModelB = await system.readModel(); expect(readModelB).toEqual(readModelA); await system.dispose(); }); @@ -248,7 +304,7 @@ describe("OrchestrationEngine", () => { title: "Project Archive", workspaceRoot: "/tmp/project-archive", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -262,7 +318,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-archive"), title: "Archive me", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -281,9 +337,8 @@ describe("OrchestrationEngine", () => { }), ); expect( - (await system.run(engine.getReadModel())).threads.find( - (thread) => thread.id === "thread-archive", - )?.archivedAt, + (await system.readModel()).threads.find((thread) => thread.id === "thread-archive") + ?.archivedAt, ).not.toBeNull(); await system.run( @@ -294,9 +349,8 @@ describe("OrchestrationEngine", () => { }), ); expect( - (await system.run(engine.getReadModel())).threads.find( - (thread) => thread.id === "thread-archive", - )?.archivedAt, + (await system.readModel()).threads.find((thread) => thread.id === "thread-archive") + ?.archivedAt, ).toBeNull(); await system.dispose(); @@ -315,7 +369,7 @@ describe("OrchestrationEngine", () => { title: "Replay Project", workspaceRoot: "/tmp/project-replay", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -329,7 +383,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "replay", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -373,7 +427,7 @@ describe("OrchestrationEngine", () => { title: "Stream Project", workspaceRoot: "/tmp/project-stream", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -397,7 +451,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "domain-stream", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -434,7 +488,7 @@ describe("OrchestrationEngine", () => { title: "Ack Project", workspaceRoot: "/tmp/project-ack", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -449,7 +503,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-ack"), title: "Ack Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -486,7 +540,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-missing"), title: "Missing Project Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -523,7 +577,7 @@ describe("OrchestrationEngine", () => { title: "Turn Diff Project", workspaceRoot: "/tmp/project-turn-diff", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -537,7 +591,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -562,7 +616,7 @@ describe("OrchestrationEngine", () => { }), ); - const thread = (await system.run(engine.getReadModel())).threads.find( + const thread = (await system.readModel()).threads.find( (entry) => entry.id === "thread-turn-diff", ); expect(thread?.checkpoints).toEqual([ @@ -642,7 +696,7 @@ describe("OrchestrationEngine", () => { title: "Flaky Project", workspaceRoot: "/tmp/project-flaky", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -658,7 +712,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-fail", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -678,7 +732,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-ok", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -690,7 +744,15 @@ describe("OrchestrationEngine", () => { ); expect(result.sequence).toBe(2); - expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2); + const eventsAfterRetry = await runtime.runPromise( + Stream.runCollect(engine.readEvents(0)).pipe( + Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), + ), + ); + expect(eventsAfterRetry.map((event) => event.type)).toEqual([ + "project.created", + "thread.created", + ]); await runtime.dispose(); }); @@ -724,6 +786,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), + Layer.provide(NodeServices.layer), ), ); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); @@ -737,7 +800,7 @@ describe("OrchestrationEngine", () => { title: "Atomic Project", workspaceRoot: "/tmp/project-atomic", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -751,7 +814,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "atomic", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -790,7 +853,6 @@ describe("OrchestrationEngine", () => { "project.created", "thread.created", ]); - expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2); const retryResult = await runtime.runPromise(engine.dispatch(turnStartCommand)); expect(retryResult.sequence).toBe(4); @@ -813,7 +875,7 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); - it("reconciles in-memory state when append persists but projection fails", async () => { + it("reconciles command state when append persists but projection fails", async () => { type StoredEvent = ReturnType extends Effect.Effect ? A @@ -845,7 +907,7 @@ describe("OrchestrationEngine", () => { projectEvent: (event) => { if ( shouldFailProjection && - event.commandId === CommandId.make("cmd-thread-meta-sync-fail") + event.commandId === CommandId.make("cmd-thread-archive-sync-fail") ) { shouldFailProjection = false; return Effect.fail( @@ -867,6 +929,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), + Layer.provide(NodeServices.layer), ), ); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); @@ -880,7 +943,7 @@ describe("OrchestrationEngine", () => { title: "Sync Project", workspaceRoot: "/tmp/project-sync", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -894,7 +957,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "sync-before", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -908,20 +971,22 @@ describe("OrchestrationEngine", () => { await expect( runtime.runPromise( engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-meta-sync-fail"), + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-sync-fail"), threadId: ThreadId.make("thread-sync"), - title: "sync-after-failed-projection", }), ), ).rejects.toThrow("projection failed"); - const readModelAfterFailure = await runtime.runPromise(engine.getReadModel()); - const updatedThread = readModelAfterFailure.threads.find( - (thread) => thread.id === "thread-sync", - ); - expect(readModelAfterFailure.snapshotSequence).toBe(3); - expect(updatedThread?.title).toBe("sync-after-failed-projection"); + await expect( + runtime.runPromise( + engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-sync-retry"), + threadId: ThreadId.make("thread-sync"), + }), + ), + ).rejects.toThrow("already archived"); await runtime.dispose(); }); @@ -965,7 +1030,7 @@ describe("OrchestrationEngine", () => { title: "Duplicate Project", workspaceRoot: "/tmp/project-duplicate", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -980,7 +1045,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1000,7 +1065,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index ddd1718faf0..cf8407bd212 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -5,20 +5,20 @@ import type { ThreadId, } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; -import { - Cause, - Deferred, - Duration, - Effect, - Exit, - Layer, - Metric, - Option, - PubSub, - Queue, - Schema, - Stream, -} from "effect"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +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 Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { @@ -34,6 +34,7 @@ import { OrchestrationCommandInvariantError, OrchestrationCommandPreviouslyRejectedError, type OrchestrationDispatchError, + type OrchestrationProjectorDecodeError, } from "../Errors.ts"; import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; @@ -43,6 +44,10 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../Services/OrchestrationEngine.ts"; +const isOrchestrationCommandPreviouslyRejectedError = Schema.is( + OrchestrationCommandPreviouslyRejectedError, +); +const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); interface CommandEnvelope { command: OrchestrationCommand; @@ -77,14 +82,27 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - let readModel = createEmptyReadModel(new Date().toISOString()); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + let commandReadModel = createEmptyReadModel(yield* nowIso); const commandQueue = yield* Queue.unbounded(); const eventPubSub = yield* PubSub.unbounded(); + const projectEventsOntoReadModel = ( + baseReadModel: OrchestrationReadModel, + events: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + let nextReadModel = baseReadModel; + for (const event of events) { + nextReadModel = yield* projectEvent(nextReadModel, event); + } + return nextReadModel; + }); + const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => { - const dispatchStartSequence = readModel.snapshotSequence; - const processingStartedAtMs = Date.now(); + const dispatchStartSequence = commandReadModel.snapshotSequence; + let processingStartedAtMs = 0; const aggregateRef = commandToAggregateRef(envelope.command); const baseMetricAttributes = { commandType: envelope.command.type, @@ -98,11 +116,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { return; } - let nextReadModel = readModel; - for (const persistedEvent of persistedEvents) { - nextReadModel = yield* projectEvent(nextReadModel, persistedEvent); - } - readModel = nextReadModel; + commandReadModel = yield* projectEventsOntoReadModel(commandReadModel, persistedEvents); for (const persistedEvent of persistedEvents) { yield* PubSub.publish(eventPubSub, persistedEvent); @@ -111,6 +125,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { return Effect.exit( Effect.gen(function* () { + processingStartedAtMs = yield* Clock.currentTimeMillis; yield* Effect.annotateCurrentSpan({ "orchestration.command_id": envelope.command.commandId, "orchestration.command_type": envelope.command.type, @@ -135,18 +150,18 @@ const makeOrchestrationEngine = Effect.gen(function* () { const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, - readModel, + readModel: commandReadModel, }); const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; const committedCommand = yield* sql .withTransaction( Effect.gen(function* () { const committedEvents: OrchestrationEvent[] = []; - let nextReadModel = readModel; + let nextCommandReadModel = commandReadModel; for (const nextEvent of eventBases) { const savedEvent = yield* eventStore.append(nextEvent); - nextReadModel = yield* projectEvent(nextReadModel, savedEvent); + nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent); yield* projectionPipeline.projectEvent(savedEvent); committedEvents.push(savedEvent); } @@ -172,7 +187,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { return { committedEvents, lastSequence: lastSavedEvent.sequence, - nextReadModel, + nextCommandReadModel, } as const; }), ) @@ -184,7 +199,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); - readModel = committedCommand.nextReadModel; + commandReadModel = committedCommand.nextCommandReadModel; for (const [index, event] of committedCommand.committedEvents.entries()) { yield* PubSub.publish(eventPubSub, event); if (index === 0) { @@ -196,7 +211,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ackEventType: event.type, }), ), - Duration.millis(Math.max(0, Date.now() - envelope.startedAtMs)), + Duration.millis(Math.max(0, (yield* Clock.currentTimeMillis) - envelope.startedAtMs)), ); } } @@ -215,7 +230,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { orchestrationCommandDuration, metricAttributes(baseMetricAttributes), ), - Duration.millis(Math.max(0, Date.now() - processingStartedAtMs)), + Duration.millis(Math.max(0, (yield* Clock.currentTimeMillis) - processingStartedAtMs)), ); yield* Metric.update( Metric.withAttributes( @@ -234,7 +249,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { } const error = Cause.squash(exit.cause) as OrchestrationDispatchError; - if (!Schema.is(OrchestrationCommandPreviouslyRejectedError)(error)) { + if (!isOrchestrationCommandPreviouslyRejectedError(error)) { yield* reconcileReadModelAfterDispatchFailure.pipe( Effect.catch(() => Effect.logWarning( @@ -242,20 +257,20 @@ const makeOrchestrationEngine = Effect.gen(function* () { ).pipe( Effect.annotateLogs({ commandId: envelope.command.commandId, - snapshotSequence: readModel.snapshotSequence, + snapshotSequence: commandReadModel.snapshotSequence, }), ), ), ); - if (Schema.is(OrchestrationCommandInvariantError)(error)) { + if (isOrchestrationCommandInvariantError(error)) { yield* commandReceiptRepository .upsert({ commandId: envelope.command.commandId, aggregateKind: aggregateRef.aggregateKind, aggregateId: aggregateRef.aggregateId, - acceptedAt: new Date().toISOString(), - resultSequence: readModel.snapshotSequence, + acceptedAt: yield* nowIso, + resultSequence: commandReadModel.snapshotSequence, status: "rejected", error: error.message, }) @@ -270,29 +285,29 @@ const makeOrchestrationEngine = Effect.gen(function* () { }; yield* projectionPipeline.bootstrap; - readModel = yield* projectionSnapshotQuery.getSnapshot(); + commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel(); const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); yield* Effect.forkScoped(worker); yield* Effect.logDebug("orchestration engine started").pipe( - Effect.annotateLogs({ sequence: readModel.snapshotSequence }), + Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }), ); - const getReadModel: OrchestrationEngineShape["getReadModel"] = () => - Effect.sync((): OrchestrationReadModel => readModel); - const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) => eventStore.readFromSequence(fromSequenceExclusive); const dispatch: OrchestrationEngineShape["dispatch"] = (command) => Effect.gen(function* () { const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); - yield* Queue.offer(commandQueue, { command, result, startedAtMs: Date.now() }); + yield* Queue.offer(commandQueue, { + command, + result, + startedAtMs: yield* Clock.currentTimeMillis, + }); return yield* Deferred.await(result); }); return { - getReadModel, readEvents, dispatch, // Each access creates a fresh PubSub subscription so that multiple diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d60f0cf7220..6155af8858a 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -1,9 +1,14 @@ -import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Scope from "effect/Scope"; import { afterEach, describe, expect, it } from "vitest"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; @@ -17,7 +22,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, and checkpoint reactors", async () => { + it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -49,10 +54,19 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadDeletionReactor, { + start: () => { + started.push("thread-deletion-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), ), ); - const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor)); + const reactor = await runtime!.runPromise(Effect.service(OrchestrationReactor)); const scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); @@ -60,6 +74,7 @@ describe("OrchestrationReactor", () => { "provider-runtime-ingestion", "provider-command-reactor", "checkpoint-reactor", + "thread-deletion-reactor", ]); await Effect.runPromise(Scope.close(scope, Exit.void)); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 99d30c57a2e..5e432d9884f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -1,4 +1,5 @@ -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { OrchestrationReactor, @@ -7,16 +8,19 @@ import { import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; + const threadDeletionReactor = yield* ThreadDeletionReactor; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); + yield* threadDeletionReactor.start(); }); return { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0796cb49056..369eea0f7a0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -7,10 +7,14 @@ import { ProjectId, ThreadId, TurnId, + ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -54,7 +58,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; yield* eventStore.append({ type: "project.created", @@ -92,7 +96,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-1"), title: "Thread 1", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -179,7 +183,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; yield* eventStore.append({ type: "thread.message-sent", @@ -223,6 +227,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( WHERE message_id = 'message-attachments' `; assert.equal(rows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ { type: "image", @@ -245,7 +250,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; yield* eventStore.append({ type: "thread.message-sent", @@ -296,6 +301,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta WHERE message_id = 'message-attachments-safe' `; assert.equal(rows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ { type: "image", @@ -325,8 +331,8 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const later = new Date(Date.now() + 1_000).toISOString(); + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-01T00:00:01.000Z"; yield* eventStore.append({ type: "project.created", @@ -364,7 +370,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-clear-attachments"), title: "Thread Clear Attachments", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -440,6 +446,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { WHERE message_id = 'message-clear-attachments' `; assert.equal(rows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []); }), ); @@ -453,8 +460,8 @@ it.layer( const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const later = new Date(Date.now() + 1_000).toISOString(); + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-01T00:00:01.000Z"; yield* eventStore.append({ type: "project.created", @@ -492,7 +499,7 @@ it.layer( projectId: ProjectId.make("project-overwrite"), title: "Thread Overwrite", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -575,6 +582,7 @@ it.layer( WHERE message_id = 'message-overwrite' `; assert.equal(rows.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ { type: "image", @@ -597,7 +605,7 @@ it.layer( const eventStore = yield* OrchestrationEventStore; const path = yield* Path.Path; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const appendAndProject = (event: Parameters[0]) => eventStore @@ -640,7 +648,7 @@ it.layer( projectId: ProjectId.make("project-rollback"), title: "Thread Rollback", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -721,7 +729,7 @@ it.layer( const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const { attachmentsDir } = yield* ServerConfig; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Revert.Files"); const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; @@ -769,7 +777,7 @@ it.layer( projectId: ProjectId.make("project-revert-files"), title: "Thread Revert Files", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -930,7 +938,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const { attachmentsDir } = yield* ServerConfig; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Delete.Files"); const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; const otherThreadAttachmentId = @@ -977,7 +985,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta projectId: ProjectId.make("project-delete-files"), title: "Thread Delete Files", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1062,7 +1070,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const path = yield* Path.Path; const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const { attachmentsDir: attachmentsRootDir, stateDir } = yield* ServerConfig; const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); @@ -1102,7 +1110,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; yield* eventStore.append({ type: "project.created", @@ -1140,7 +1148,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-a"), title: "Thread A", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1229,7 +1237,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; yield* eventStore.append({ type: "project.created", @@ -1267,7 +1275,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-empty"), title: "Thread Empty", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1407,7 +1415,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-conflict"), title: "Thread Conflict", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1505,6 +1513,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("clears stale pending approvals from projected shell summaries", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-stale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-stale-approval"), + occurredAt: "2026-02-26T12:30:00.000Z", + commandId: CommandId.make("cmd-stale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-stale-approval"), + title: "Project Stale Approval", + workspaceRoot: "/tmp/project-stale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:30:00.000Z", + updatedAt: "2026-02-26T12:30:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-stale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:01.000Z", + commandId: CommandId.make("cmd-stale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + projectId: ProjectId.make("project-stale-approval"), + title: "Thread Stale Approval", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:30:01.000Z", + updatedAt: "2026-02-26T12:30:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:02.000Z", + commandId: CommandId.make("cmd-stale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("activity-stale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-stale-1", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:30:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:03.000Z", + commandId: CommandId.make("cmd-stale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("activity-stale-approval-failed"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-stale-1", + detail: "Unknown pending permission request: approval-request-stale-1", + }, + turnId: null, + createdAt: "2026-02-26T12:30:03.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-request-stale-1' + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-stale-1", + status: "resolved", + resolvedAt: "2026-02-26T12:30:03.000Z", + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-stale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]); + }), + ); + + it.effect("ignores non-stale provider approval response failures", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-nonstale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-nonstale-approval"), + occurredAt: "2026-02-26T12:45:00.000Z", + commandId: CommandId.make("cmd-nonstale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-nonstale-approval"), + title: "Project Non-Stale Approval", + workspaceRoot: "/tmp/project-nonstale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:45:00.000Z", + updatedAt: "2026-02-26T12:45:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-nonstale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:01.000Z", + commandId: CommandId.make("cmd-nonstale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + projectId: ProjectId.make("project-nonstale-approval"), + title: "Thread Non-Stale Approval", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:45:01.000Z", + updatedAt: "2026-02-26T12:45:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:02.000Z", + commandId: CommandId.make("cmd-nonstale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-nonstale-existing", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:03.000Z", + commandId: CommandId.make("cmd-nonstale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-failed-existing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-existing", + detail: "Provider timed out while responding to approval request", + }, + turnId: TurnId.make("turn-nonstale-failure"), + createdAt: "2026-02-26T12:45:03.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:04.000Z", + commandId: CommandId.make("cmd-nonstale-approval-5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-failed-missing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-missing", + detail: "Provider timed out while responding to approval request", + }, + turnId: null, + createdAt: "2026-02-26T12:45:04.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly turnId: string | null; + readonly createdAt: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + turn_id AS "turnId", + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id IN ( + 'approval-request-nonstale-existing', + 'approval-request-nonstale-missing' + ) + ORDER BY request_id + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-nonstale-existing", + status: "pending", + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + resolvedAt: null, + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-nonstale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]); + }), + ); + it.effect("does not fallback-retain messages whose turnId is removed by revert", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -1551,7 +1882,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.make("project-revert"), title: "Thread Revert", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -1863,7 +2194,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { Effect.gen(function* () { const engine = yield* OrchestrationEngineService; const sql = yield* SqlClient.SqlClient; - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; yield* engine.dispatch({ type: "project.create", @@ -1872,7 +2203,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { title: "Live Project", workspaceRoot: "/tmp/project-live", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -1901,7 +2232,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { Effect.gen(function* () { const engine = yield* OrchestrationEngineService; const sql = yield* SqlClient.SqlClient; - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; yield* engine.dispatch({ type: "project.create", @@ -1910,7 +2241,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { title: "Scripts Project", workspaceRoot: "/tmp/project-scripts", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -1930,7 +2261,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }, ], defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, }); @@ -1949,7 +2280,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { { scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', - defaultModelSelection: '{"provider":"codex","model":"gpt-5"}', + defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index bdc6fac6d1c..1161ff6a7d7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -2,8 +2,14 @@ import { ApprovalRequestId, type ChatAttachment, type OrchestrationEvent, + ThreadId, } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +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 Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; @@ -89,6 +95,88 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; } +function isStalePendingApprovalFailureDetail(detail: string | null): boolean { + if (detail === null) { + return false; + } + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") + ); +} + +function derivePendingUserInputCountFromActivities( + activities: ReadonlyArray, +): number { + const openRequestIds = new Set(); + const ordered = [...activities].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + + for (const activity of ordered) { + const requestId = extractActivityRequestId(activity.payload); + if (requestId === null) { + continue; + } + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + + if (activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + continue; + } + + if (activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.user-input.respond.failed" && + detail !== null && + (detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request")) + ) { + openRequestIds.delete(requestId); + } + } + + return openRequestIds.size; +} + +function deriveHasActionableProposedPlan(input: { + readonly latestTurnId: string | null; + readonly proposedPlans: ReadonlyArray; +}): boolean { + const sorted = [...input.proposedPlans].toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), + ); + + let latestForTurn: ProjectionThreadProposedPlan | null = null; + if (input.latestTurnId !== null) { + for (let index = sorted.length - 1; index >= 0; index -= 1) { + const plan = sorted[index]; + if (plan?.turnId === input.latestTurnId) { + latestForTurn = plan; + break; + } + } + } + if (latestForTurn !== null) { + return latestForTurn.implementedAt === null; + } + + const latestPlan = sorted.at(-1) ?? null; + return latestPlan !== null && latestPlan.implementedAt === null; +} + function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, @@ -432,6 +520,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* ( + threadId: ThreadId, + ) { + const existingRow = yield* projectionThreadRepository.getById({ + threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + + const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ + projectionThreadMessageRepository.listByThreadId({ threadId }), + projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionPendingApprovalRepository.listByThreadId({ threadId }), + ]); + + const latestUserMessageAt = + messages + .filter((message) => message.role === "user") + .map((message) => message.createdAt) + .toSorted() + .at(-1) ?? null; + + const pendingApprovalCount = pendingApprovals.filter( + (approval) => approval.status === "pending", + ).length; + const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); + const hasActionableProposedPlan = deriveHasActionableProposedPlan({ + latestTurnId: existingRow.value.latestTurnId, + proposedPlans, + }); + + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestUserMessageAt, + pendingApprovalCount, + pendingUserInputCount, + hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, + }); + }); + const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadsProjection", )(function* (event, attachmentSideEffects) { @@ -450,6 +580,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); return; @@ -554,7 +688,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": - case "thread.activity-appended": { + case "thread.activity-appended": + case "thread.approval-response-requested": + case "thread.user-input-response-requested": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -565,6 +701,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -580,6 +717,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.session.activeTurnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -595,6 +733,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.turnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -605,11 +744,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } + + const retainedTurns = yield* projectionTurnRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + let latestTurnId: ProjectionTurn["turnId"] = null; + let latestCheckpointTurnCount = -1; + for (let index = 0; index < retainedTurns.length; index += 1) { + const turn = retainedTurns[index]; + if ( + !turn || + turn.turnId === null || + turn.checkpointTurnCount === null || + turn.checkpointTurnCount > event.payload.turnCount + ) { + continue; + } + if (turn.checkpointTurnCount > latestCheckpointTurnCount) { + latestCheckpointTurnCount = turn.checkpointTurnCount; + latestTurnId = turn.turnId; + } + } + yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: null, + latestTurnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -810,6 +972,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, status: event.payload.session.status, providerName: event.payload.session.providerName, + providerInstanceId: event.payload.session.providerInstanceId ?? null, runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, @@ -1121,6 +1284,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; } + if (event.payload.activity.kind === "provider.approval.respond.failed") { + const payload = + typeof event.payload.activity.payload === "object" && + event.payload.activity.payload !== null + ? (event.payload.activity.payload as Record) + : null; + const detail = + typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (isStalePendingApprovalFailureDetail(detail)) { + if (Option.isNone(existingRow)) { + return; + } + if (existingRow.value.status === "resolved") { + return; + } + yield* projectionPendingApprovalRepository.upsert({ + requestId, + threadId: existingRow.value.threadId, + turnId: existingRow.value.turnId, + status: "resolved", + decision: null, + createdAt: existingRow.value.createdAt, + resolvedAt: event.payload.activity.createdAt, + }); + return; + } + return; + } + // Only approval-requested activities should create pending-approval + // rows. Other activity kinds that happen to carry a requestId + // (e.g. user-input.requested / user-input.resolved) must not + // pollute this projection — they have their own accounting via + // derivePendingUserInputCountFromActivities. + if (event.payload.activity.kind !== "approval.requested") { + return; + } if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 0eb312cbfdc..7db2a23e5ec 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,9 +1,20 @@ -import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + CheckpointRef, + EventId, + MessageId, + ProjectId, + ThreadId, + TurnId, + ProviderInstanceId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -19,6 +30,7 @@ const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), ), ); @@ -62,9 +74,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 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, deleted_at @@ -74,9 +92,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'project-1', 'Thread 1', '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', NULL, NULL, 'turn-1', + '2026-02-24T00:00:04.000Z', + 1, + 0, + 0, '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -240,7 +264,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { workspaceRoot: "/tmp/project-1", repositoryIdentity: null, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [ @@ -263,7 +287,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { projectId: asProjectId("project-1"), title: "Thread 1", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "default", @@ -341,6 +365,201 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, }, ]); + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.equal(shellSnapshot.snapshotSequence, 5); + assert.deepEqual(shellSnapshot.projects, [ + { + id: asProjectId("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + scripts: [ + { + id: "script-1", + name: "Build", + command: "bun run build", + icon: "build", + runOnWorktreeCreate: false, + }, + ], + createdAt: "2026-02-24T00:00:00.000Z", + updatedAt: "2026-02-24T00:00:01.000Z", + }, + ]); + assert.deepEqual(shellSnapshot.threads, [ + { + id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread 1", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: { + turnId: asTurnId("turn-1"), + state: "completed", + requestedAt: "2026-02-24T00:00:08.000Z", + startedAt: "2026-02-24T00:00:08.000Z", + completedAt: "2026-02-24T00:00:08.000Z", + assistantMessageId: asMessageId("message-1"), + sourceProposedPlan: { + threadId: ThreadId.make("thread-1"), + planId: "plan-1", + }, + }, + createdAt: "2026-02-24T00:00:02.000Z", + updatedAt: "2026-02-24T00:00:03.000Z", + archivedAt: null, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: "2026-02-24T00:00:07.000Z", + }, + latestUserMessageAt: "2026-02-24T00:00:04.000Z", + hasPendingApprovals: true, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ]); + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value, snapshot.threads[0]); + } + }), + ); + + it.effect("keeps archived threads out of the main shell snapshot", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + 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-archive-test', + 'Archive Test', + '/tmp/archive-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00: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-archive-test', + 'Active Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:03.000Z', + NULL, + NULL + ), + ( + 'thread-archived', + 'project-archive-test', + 'Archived Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:04.000Z', + '2026-04-06T00:00:05.000Z', + '2026-04-06T00:00:06.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + `; + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-active")], + ); + + const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); + assert.deepEqual( + archivedShellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-archived")], + ); + assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); }), ); @@ -629,4 +848,683 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { } }), ); -}); + + it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + 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-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-01T00:00:00.000Z', + '2026-04-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, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-01T00:00:02.000Z', + '2026-04-01T00:00:03.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-unsequenced', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'unsequenced first', + '{"source":"unsequenced"}', + NULL, + '2026-04-01T00:00:06.000Z' + ), + ( + 'activity-sequence-2', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence two', + '{"source":"sequence-2"}', + 2, + '2026-04-01T00:00:04.000Z' + ), + ( + 'activity-sequence-1', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence one', + '{"source":"sequence-1"}', + 1, + '2026-04-01T00:00:05.000Z' + ) + `; + + const snapshot = yield* snapshotQuery.getSnapshot(); + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); + } + + assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ + { + id: asEventId("activity-unsequenced"), + tone: "info", + kind: "runtime.note", + summary: "unsequenced first", + payload: { source: "unsequenced" }, + turnId: null, + createdAt: "2026-04-01T00:00:06.000Z", + }, + { + id: asEventId("activity-sequence-1"), + tone: "info", + kind: "runtime.note", + summary: "sequence one", + payload: { source: "sequence-1" }, + turnId: null, + sequence: 1, + createdAt: "2026-04-01T00:00:05.000Z", + }, + { + id: asEventId("activity-sequence-2"), + tone: "info", + kind: "runtime.note", + summary: "sequence two", + payload: { source: "sequence-2" }, + turnId: null, + sequence: 2, + createdAt: "2026-04-01T00:00:04.000Z", + }, + ]); + }), + ); + + it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + + 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-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-02T00:00:00.000Z', + '2026-04-02T00: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-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + 'turn-running', + '2026-04-02T00:00:04.000Z', + 0, + 0, + 0, + '2026-04-02T00:00:02.000Z', + '2026-04-02T00:00:03.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + 'thread-1', + 'turn-completed', + 'message-user-1', + NULL, + NULL, + 'message-assistant-1', + 'completed', + '2026-04-02T00:00:05.000Z', + '2026-04-02T00:00:06.000Z', + '2026-04-02T00:00:20.000Z', + 5, + 'checkpoint-5', + 'ready', + '[]' + ), + ( + 'thread-1', + 'turn-running', + 'message-user-2', + NULL, + NULL, + NULL, + 'running', + '2026-04-02T00:00:30.000Z', + '2026-04-02T00:00:30.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(threadShell._tag, "Some"); + if (threadShell._tag === "Some") { + assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadShell.value.latestTurn?.state, "running"); + assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadDetail.value.latestTurn?.state, "running"); + assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + }), + ); + + it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_state`; + + 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-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-03T00:00:00.000Z', + '2026-04-03T00: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-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + 'turn-running', + '2026-04-03T00:00:04.000Z', + 0, + 0, + 0, + '2026-04-03T00:00:02.000Z', + '2026-04-03T00:00:03.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + 'thread-1', + 'turn-running', + 'message-user-2', + NULL, + NULL, + NULL, + 'running', + '2026-04-03T00:00:30.000Z', + '2026-04-03T00:00:30.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ), + ( + 'thread-1', + 'turn-completed', + 'message-user-1', + NULL, + NULL, + 'message-assistant-1', + 'completed', + '2026-04-03T00:00:05.000Z', + '2026-04-03T00:00:06.000Z', + '2026-04-03T00:00:20.000Z', + NULL, + NULL, + NULL, + '[]' + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 3, '2026-04-03T00:00:40.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 3, '2026-04-03T00:00:40.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 3, '2026-04-03T00:00:40.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 3, '2026-04-03T00:00:40.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 3, '2026-04-03T00:00:40.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 3, '2026-04-03T00:00:40.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 3, '2026-04-03T00:00:40.000Z') + `; + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "running"); + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.equal(shellSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(shellSnapshot.threads[0]?.latestTurn?.state, "running"); + + const fullSnapshot = yield* snapshotQuery.getSnapshot(); + assert.equal(fullSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(fullSnapshot.threads[0]?.latestTurn?.state, "running"); + }), + ); + + it.effect("keeps deleted project and thread tombstones in the command read model", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_state`; + + 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-deleted', + 'Deleted Project', + '/tmp/deleted-project', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-05T00:00:00.000Z', + '2026-04-05T00:00:01.000Z', + '2026-04-05T00:00:02.000Z' + ) + `; + + 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-deleted', + 'project-deleted', + 'Deleted Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + 'turn-deleted', + NULL, + 0, + 0, + 0, + '2026-04-05T00:00:03.000Z', + '2026-04-05T00:00:04.000Z', + NULL, + '2026-04-05T00:00:05.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + 'thread-deleted', + 'turn-deleted', + 'message-deleted-user', + NULL, + NULL, + 'message-deleted-assistant', + 'completed', + '2026-04-05T00:00:04.100Z', + '2026-04-05T00:00:04.200Z', + '2026-04-05T00:00:04.300Z', + NULL, + NULL, + NULL, + '[]' + ) + `; + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.equal(commandReadModel.projects[0]?.id, asProjectId("project-deleted")); + assert.equal(commandReadModel.projects[0]?.deletedAt, "2026-04-05T00:00:02.000Z"); + assert.equal(commandReadModel.threads[0]?.id, ThreadId.make("thread-deleted")); + assert.equal(commandReadModel.threads[0]?.deletedAt, "2026-04-05T00:00:05.000Z"); + assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-deleted")); + assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "completed"); + + const fullSnapshot = yield* snapshotQuery.getSnapshot(); + assert.equal(fullSnapshot.threads[0]?.id, ThreadId.make("thread-deleted")); + assert.equal(fullSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-deleted")); + assert.equal(fullSnapshot.threads[0]?.latestTurn?.state, "completed"); + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.equal(shellSnapshot.projects.length, 0); + assert.equal(shellSnapshot.threads.length, 0); + }), + ); +}); + +it.effect( + "ProjectionSnapshotQuery dedupes repository identity resolution by workspace root and skips deleted projects for shell snapshots", + () => { + const resolveCalls: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provideMerge( + Layer.succeed(RepositoryIdentityResolver, { + resolve: (cwd: string) => + Effect.sync(() => { + resolveCalls.push(cwd); + return { + canonicalKey: `github.com/acme${cwd}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://github.com/acme${cwd}.git`, + }, + rootPath: cwd, + }; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_state`; + + 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-1', + 'Shared Project 1', + '/tmp/shared-root', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-04T00:00:00.000Z', + '2026-04-04T00:00:01.000Z', + NULL + ), + ( + 'project-2', + 'Shared Project 2', + '/tmp/shared-root', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-04T00:00:02.000Z', + '2026-04-04T00:00:03.000Z', + NULL + ), + ( + 'project-3', + 'Deleted Project', + '/tmp/deleted-root', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-04T00:00:04.000Z', + '2026-04-04T00:00:05.000Z', + '2026-04-04T00:00:06.000Z' + ) + `; + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepStrictEqual(resolveCalls.toSorted(), ["/tmp/shared-root"]); + assert.equal(shellSnapshot.projects.length, 2); + assert.equal(shellSnapshot.projects[0]?.repositoryIdentity?.rootPath, "/tmp/shared-root"); + assert.equal(shellSnapshot.projects[1]?.repositoryIdentity?.rootPath, "/tmp/shared-root"); + + resolveCalls.length = 0; + + const fullSnapshot = yield* snapshotQuery.getSnapshot(); + assert.deepStrictEqual(resolveCalls.toSorted(), ["/tmp/deleted-root", "/tmp/shared-root"]); + assert.equal(fullSnapshot.projects.length, 3); + assert.equal(fullSnapshot.projects[2]?.repositoryIdentity?.rootPath, "/tmp/deleted-root"); + }).pipe(Effect.provide(layer)); + }, +); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index b0f883f9402..9b3c0fa7ad4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,26 +1,34 @@ import { ChatAttachment, + CheckpointRef, IsoDateTime, MessageId, NonNegativeInt, OrchestrationCheckpointFile, OrchestrationProposedPlanId, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThread, ProjectScript, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, type OrchestrationMessage, + type OrchestrationProjectShell, type OrchestrationProposedPlan, type OrchestrationProject, type OrchestrationSession, - type OrchestrationThread, type OrchestrationThreadActivity, + type OrchestrationThreadShell, ModelSelection, ProjectId, ThreadId, } from "@t3tools/contracts"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +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 Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; @@ -42,12 +50,15 @@ import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIde import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, + type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); +const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); +const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -113,6 +124,18 @@ const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ workspaceRoot: Schema.String, worktreePath: Schema.NullOr(Schema.String), }); +const FullThreadDiffContextLookupInput = Schema.Struct({ + threadId: ThreadId, + checkpointTurnCount: NonNegativeInt, +}); +const ProjectionFullThreadDiffContextRowSchema = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + workspaceRoot: Schema.String, + worktreePath: Schema.NullOr(Schema.String), + latestCheckpointTurnCount: Schema.NullOr(NonNegativeInt), + toCheckpointRef: Schema.NullOr(CheckpointRef), +}); const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, @@ -155,6 +178,79 @@ function computeSnapshotSequence( return Number.isFinite(minSequence) ? minSequence : 0; } +function mapLatestTurn( + row: Schema.Schema.Type, +): OrchestrationLatestTurn { + return { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }; +} + +function mapSessionRow( + row: Schema.Schema.Type, +): OrchestrationSession { + return { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}), + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }; +} + +function mapProjectShellRow( + row: Schema.Schema.Type, + repositoryIdentity: OrchestrationProject["repositoryIdentity"], +): OrchestrationProjectShell { + return { + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + repositoryIdentity, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function mapProposedPlanRow( + row: Schema.Schema.Type, +): OrchestrationProposedPlan { + return { + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -166,6 +262,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; + const resolveRepositoryIdentitiesForProjects = Effect.fn( + "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", + )(function* ( + projectRows: ReadonlyArray>, + options?: { + readonly includeDeleted?: boolean; + }, + ) { + const filteredProjectRows = + options?.includeDeleted === true + ? projectRows + : projectRows.filter((row) => row.deletedAt === null); + const uniqueWorkspaceRoots = [...new Set(filteredProjectRows.map((row) => row.workspaceRoot))]; + const repositoryIdentityByWorkspaceRoot = new Map( + yield* Effect.forEach( + uniqueWorkspaceRoots, + (workspaceRoot) => + repositoryIdentityResolver + .resolve(workspaceRoot) + .pipe(Effect.map((identity) => [workspaceRoot, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + + return new Map( + filteredProjectRows.map((row) => [ + row.projectId, + repositoryIdentityByWorkspaceRoot.get(row.workspaceRoot) ?? null, + ]), + ); + }); const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, @@ -204,12 +331,76 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads ORDER BY created_at ASC, thread_id ASC `, }); + const listActiveThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE deleted_at IS NULL + AND archived_at IS NULL + ORDER BY project_id ASC, created_at ASC, thread_id ASC + `, + }); + + const listArchivedThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE deleted_at IS NULL + AND archived_at IS NOT NULL + ORDER BY project_id ASC, archived_at DESC, thread_id DESC + `, + }); + const listThreadMessageRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadMessageDbRowSchema, @@ -267,7 +458,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_thread_activities ORDER BY thread_id ASC, - CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, sequence ASC, created_at ASC, activity_id ASC @@ -283,6 +473,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", status, provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", provider_session_id AS "providerSessionId", provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", @@ -294,6 +485,56 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listActiveThreadSessionRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadSessionDbRowSchema, + execute: () => + sql` + SELECT + sessions.thread_id AS "threadId", + sessions.status, + sessions.provider_name AS "providerName", + sessions.provider_instance_id AS "providerInstanceId", + sessions.provider_session_id AS "providerSessionId", + sessions.provider_thread_id AS "providerThreadId", + sessions.runtime_mode AS "runtimeMode", + sessions.active_turn_id AS "activeTurnId", + sessions.last_error AS "lastError", + sessions.updated_at AS "updatedAt" + FROM projection_thread_sessions sessions + INNER JOIN projection_threads threads + ON threads.thread_id = sessions.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY sessions.thread_id ASC + `, + }); + + const listArchivedThreadSessionRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadSessionDbRowSchema, + execute: () => + sql` + SELECT + sessions.thread_id AS "threadId", + sessions.status, + sessions.provider_name AS "providerName", + sessions.provider_instance_id AS "providerInstanceId", + sessions.provider_session_id AS "providerSessionId", + sessions.provider_thread_id AS "providerThreadId", + sessions.runtime_mode AS "runtimeMode", + sessions.active_turn_id AS "activeTurnId", + sessions.last_error AS "lastError", + sessions.updated_at AS "updatedAt" + FROM projection_thread_sessions sessions + INNER JOIN projection_threads threads + ON threads.thread_id = sessions.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + ORDER BY sessions.thread_id ASC + `, + }); + const listCheckpointRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionCheckpointDbRowSchema, @@ -320,18 +561,73 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: () => sql` SELECT - thread_id AS "threadId", - turn_id AS "turnId", - state, - requested_at AS "requestedAt", - started_at AS "startedAt", - completed_at AS "completedAt", - assistant_message_id AS "assistantMessageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId" - FROM projection_turns - WHERE turn_id IS NOT NULL - ORDER BY thread_id ASC, requested_at DESC, turn_id DESC + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.latest_turn_id IS NOT NULL + ORDER BY turns.thread_id ASC + `, + }); + + const listActiveLatestTurnRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionLatestTurnDbRowSchema, + execute: () => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND threads.latest_turn_id IS NOT NULL + ORDER BY turns.thread_id ASC + `, + }); + + const listArchivedLatestTurnRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionLatestTurnDbRowSchema, + execute: () => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + AND threads.latest_turn_id IS NOT NULL + ORDER BY turns.thread_id ASC `, }); @@ -381,6 +677,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getActiveProjectRowById = SqlSchema.findOneOption({ + Request: ProjectIdLookupInput, + Result: ProjectionProjectLookupRowSchema, + execute: ({ projectId }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE project_id = ${projectId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ Request: ProjectIdLookupInput, Result: ProjectionThreadIdLookupRowSchema, @@ -391,6 +708,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE project_id = ${projectId} AND deleted_at IS NULL + AND archived_at IS NULL ORDER BY created_at ASC, thread_id ASC LIMIT 1 `, @@ -415,6 +733,148 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getActiveThreadRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + AND deleted_at IS NULL + AND archived_at IS NULL + LIMIT 1 + `, + }); + + const listThreadMessageRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const listThreadProposedPlanRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadProposedPlanDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const listThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const getThreadSessionRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadSessionDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", + runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + + const getLatestTurnRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionLatestTurnDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL + LIMIT 1 + `, + }); + const listCheckpointRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionCheckpointDbRowSchema, @@ -436,6 +896,38 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ + Request: FullThreadDiffContextLookupInput, + Result: ProjectionFullThreadDiffContextRowSchema, + execute: ({ threadId, checkpointTurnCount }) => + sql` + SELECT + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + projects.workspace_root AS "workspaceRoot", + threads.worktree_path AS "worktreePath", + ( + SELECT MAX(turns.checkpoint_turn_count) + FROM projection_turns AS turns + WHERE turns.thread_id = threads.thread_id + AND turns.checkpoint_turn_count IS NOT NULL + ) AS "latestCheckpointTurnCount", + ( + SELECT turns.checkpoint_ref + FROM projection_turns AS turns + WHERE turns.thread_id = threads.thread_id + AND turns.checkpoint_turn_count = ${checkpointTurnCount} + LIMIT 1 + ) AS "toCheckpointRef" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + LIMIT 1 + `, + }); + const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( @@ -651,6 +1143,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadId: row.threadId, status: row.status, providerName: row.providerName, + ...(row.providerInstanceId !== null + ? { providerInstanceId: row.providerInstanceId } + : {}), runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, @@ -658,15 +1153,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } - const repositoryIdentities = new Map( - yield* Effect.forEach( - projectRows, - (row) => - repositoryIdentityResolver - .resolve(row.workspaceRoot) - .pipe(Effect.map((identity) => [row.projectId, identity] as const)), - { concurrency: repositoryIdentityResolutionConcurrency }, - ), + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows, + { includeDeleted: true }, ); const projects: ReadonlyArray = projectRows.map((row) => ({ @@ -706,7 +1195,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, - updatedAt: updatedAt ?? new Date(0).toISOString(), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; return yield* decodeReadModel(snapshot).pipe( @@ -724,8 +1213,477 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); - const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => - readProjectionCounts(undefined).pipe( + const getCommandReadModel: ProjectionSnapshotQueryShape["getCommandReadModel"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listProjects:query", + "ProjectionSnapshotQuery.getCommandReadModel:listProjects:decodeRows", + ), + ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreads:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreads:decodeRows", + ), + ), + ), + listThreadProposedPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadProposedPlans:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadProposedPlans:decodeRows", + ), + ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadSessions:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadSessions:decodeRows", + ), + ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listLatestTurns:query", + "ProjectionSnapshotQuery.getCommandReadModel:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listProjectionState:query", + "ProjectionSnapshotQuery.getCommandReadModel:listProjectionState:decodeRows", + ), + ), + ), + ]), + ) + .pipe( + Effect.flatMap( + ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + Effect.sync(() => { + let updatedAt: string | null = null; + const projects: OrchestrationProject[] = []; + const threads: OrchestrationThread[] = []; + + for (let index = 0; index < projectRows.length; index += 1) { + const row = projectRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + projects.push({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + }); + } + for (let index = 0; index < threadRows.length; index += 1) { + const row = threadRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (let index = 0; index < proposedPlanRows.length; index += 1) { + const row = proposedPlanRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (let index = 0; index < sessionRows.length; index += 1) { + const row = sessionRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (let index = 0; index < latestTurnRows.length; index += 1) { + const row = latestTurnRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (let index = 0; index < stateRows.length; index += 1) { + const row = stateRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const latestTurnByThread = new Map(); + for (let index = 0; index < latestTurnRows.length; index += 1) { + const row = latestTurnRows[index]; + if (!row) { + continue; + } + latestTurnByThread.set(row.threadId, mapLatestTurn(row)); + } + const proposedPlansByThread = new Map>(); + const sessionByThread = new Map(); + + for (let index = 0; index < sessionRows.length; index += 1) { + const row = sessionRows[index]; + if (!row) { + continue; + } + sessionByThread.set(row.threadId, mapSessionRow(row)); + } + + for (let index = 0; index < proposedPlanRows.length; index += 1) { + const row = proposedPlanRows[index]; + if (!row) { + continue; + } + const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; + threadProposedPlans.push(mapProposedPlanRow(row)); + proposedPlansByThread.set(row.threadId, threadProposedPlans); + } + + for (let index = 0; index < threadRows.length; index += 1) { + const row = threadRows[index]; + if (!row) { + continue; + } + threads.push({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + messages: [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: [], + checkpoints: [], + session: sessionByThread.get(row.threadId) ?? null, + }); + } + + return { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + } satisfies OrchestrationReadModel; + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getCommandReadModel:query")(error); + }), + ); + + const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeRows", + ), + ), + ), + listActiveThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeRows", + ), + ), + ), + listActiveThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listActiveLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]), + ) + .pipe( + Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => row.deletedAt === null) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows + .filter((row) => row.deletedAt === null) + .map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error); + }), + ); + + const getArchivedShellSnapshot: ProjectionSnapshotQueryShape["getArchivedShellSnapshot"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjects:decodeRows", + ), + ), + ), + listArchivedThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreads:decodeRows", + ), + ), + ), + listArchivedThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listArchivedLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]), + ) + .pipe( + Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => row.deletedAt === null && activeProjectIds.has(row.projectId)) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows.map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getArchivedShellSnapshot:query")( + error, + ); + }), + ); + + const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () => + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshotSequence:query", + "ProjectionSnapshotQuery.getSnapshotSequence:decodeRows", + ), + ), + Effect.map((stateRows) => ({ + snapshotSequence: computeSnapshotSequence(stateRows), + })), + ); + + const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => + readProjectionCounts(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getCounts:query", @@ -770,6 +1728,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"] = (projectId) => + getActiveProjectRowById({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectShellById:query", + "ProjectionSnapshotQuery.getProjectShellById:decodeRow", + ), + ), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver + .resolve(option.value.workspaceRoot) + .pipe( + Effect.map((repositoryIdentity) => + Option.some(mapProjectShellRow(option.value, repositoryIdentity)), + ), + ), + ), + ); + const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = (projectId) => getFirstActiveThreadIdByProject({ projectId }).pipe( @@ -826,12 +1805,242 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); + const getFullThreadDiffContext: NonNullable< + ProjectionSnapshotQueryShape["getFullThreadDiffContext"] + > = (threadId, toTurnCount) => + Effect.gen(function* () { + const row = yield* getFullThreadDiffContextRow({ + threadId, + checkpointTurnCount: toTurnCount, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getFullThreadDiffContext:query", + "ProjectionSnapshotQuery.getFullThreadDiffContext:decodeRow", + ), + ), + ); + if (Option.isNone(row)) { + return Option.none(); + } + + return Option.some({ + threadId: row.value.threadId, + projectId: row.value.projectId, + workspaceRoot: row.value.workspaceRoot, + worktreePath: row.value.worktreePath, + latestCheckpointTurnCount: row.value.latestCheckpointTurnCount ?? 0, + toCheckpointRef: row.value.toCheckpointRef, + }); + }); + + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => + Effect.gen(function* () { + const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getThread:query", + "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getSession:query", + "ProjectionSnapshotQuery.getThreadShellById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + return Option.some({ + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + latestUserMessageAt: threadRow.value.latestUserMessageAt, + hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, + hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, + hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, + } satisfies OrchestrationThreadShell); + }); + + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + Effect.gen(function* () { + const [ + threadRow, + messageRows, + proposedPlanRows, + activityRows, + checkpointRows, + latestTurnRow, + sessionRow, + ] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", + "ProjectionSnapshotQuery.getThreadDetailById:getThread:decodeRow", + ), + ), + ), + listThreadMessageRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:decodeRows", + ), + ), + ), + listThreadProposedPlanRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:decodeRows", + ), + ), + ), + listThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + listCheckpointRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query", + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:decodeRows", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getSession:query", + "ProjectionSnapshotQuery.getThreadDetailById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + const thread = { + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + deletedAt: null, + messages: messageRows.map((row) => { + const message = { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (row.attachments !== null) { + return Object.assign(message, { attachments: row.attachments }); + } + return message; + }), + proposedPlans: proposedPlanRows.map(mapProposedPlanRow), + activities: activityRows.map((row) => { + const activity = { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }; + if (row.sequence !== null) { + return Object.assign(activity, { sequence: row.sequence }); + } + return activity; + }), + checkpoints: checkpointRows.map((row) => ({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + })), + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + }; + + return Option.some( + yield* decodeThread(thread).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getThreadDetailById:decodeThread"), + ), + ), + ); + }); + return { + getCommandReadModel, getSnapshot, + getShellSnapshot, + getArchivedShellSnapshot, + getSnapshotSequence, getCounts, getActiveProjectByWorkspaceRoot, + getProjectShellById, getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, + getFullThreadDiffContext, + getThreadShellById, + getThreadDetailById, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ded64fb9e6c..571164fad93 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1,8 +1,16 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import { + ModelSelection, + ProviderRuntimeEvent, + ProviderSession, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { ApprovalRequestId, CommandId, @@ -13,7 +21,13 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; @@ -26,17 +40,24 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; -import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; -import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; -import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; +import { + providerErrorLabel, + providerErrorLabelFromInstanceHint, + ProviderCommandReactorLive, +} from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -50,15 +71,15 @@ async function waitFor( predicate: () => boolean | Promise, timeoutMs = 2000, ): Promise { - const deadline = Date.now() + timeoutMs; + const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; const poll = async (): Promise => { if (await predicate()) { return; } - if (Date.now() >= deadline) { + if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) { throw new Error("Timed out waiting for expectation."); } - await new Promise((resolve) => setTimeout(resolve, 10)); + await Effect.runPromise(Effect.yieldNow); return poll(); }; @@ -67,7 +88,7 @@ async function waitFor( describe("ProviderCommandReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | ProviderCommandReactor, + OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -93,12 +114,36 @@ describe("ProviderCommandReactor", () => { createdBaseDirs.clear(); }); + describe("provider error attribution", () => { + it("uses the current provider instance slug when current instance lookup fails", () => { + expect( + providerErrorLabelFromInstanceHint({ + instanceId: "codex_personal", + modelSelectionInstanceId: "codex", + sessionProvider: "codex", + }), + ).toBe("codex_personal"); + }); + + it("uses the desired provider instance slug when desired instance lookup fails", () => { + expect( + providerErrorLabelFromInstanceHint({ + instanceId: "claude_openrouter", + }), + ).toBe("claude_openrouter"); + }); + + it("uses the unknown driver kind when the resolved driver is not registered locally", () => { + expect(providerErrorLabel("third_party_driver")).toBe("third_party_driver"); + }); + }); + async function createHarness(input?: { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; }) { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); createdBaseDirs.add(baseDir); const { stateDir } = deriveServerPathsSync(baseDir, undefined); @@ -107,7 +152,7 @@ describe("ProviderCommandReactor", () => { let nextSessionIndex = 1; const runtimeSessions: Array = []; const modelSelection = input?.threadModelSelection ?? { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }; const startSession = vi.fn((_: unknown, input: unknown) => { @@ -123,8 +168,24 @@ describe("ProviderCommandReactor", () => { typeof input.threadId === "string" ? ThreadId.make(input.threadId) : ThreadId.make(`thread-${sessionIndex}`); + const inputModelSelection = + typeof input === "object" && input !== null && "modelSelection" in input + ? (input.modelSelection as ModelSelection | undefined) + : undefined; + const providerInstanceId = + typeof input === "object" && input !== null && "providerInstanceId" in input + ? (input.providerInstanceId as ProviderInstanceId | undefined) + : inputModelSelection?.instanceId; + const provider = + typeof input === "object" && + input !== null && + "provider" in input && + typeof input.provider === "string" + ? (input.provider as ProviderSession["provider"]) + : ProviderDriverKind.make(inputModelSelection?.instanceId ?? modelSelection.instanceId); const session: ProviderSession = { - provider: modelSelection.provider, + provider, + ...(providerInstanceId ? { providerInstanceId } : {}), status: "ready" as const, runtimeMode: typeof input === "object" && @@ -133,7 +194,15 @@ describe("ProviderCommandReactor", () => { (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") ? input.runtimeMode : "full-access", - ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}), + ...(typeof input === "object" && + input !== null && + "cwd" in input && + typeof input.cwd === "string" + ? { cwd: input.cwd } + : {}), + ...((inputModelSelection?.model ?? modelSelection.model) + ? { model: inputModelSelection?.model ?? modelSelection.model } + : {}), threadId, resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, createdAt: now, @@ -177,6 +246,24 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "renamed-branch", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ @@ -207,6 +294,25 @@ describe("ProviderCommandReactor", () => { Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", }), + getInstanceInfo: (instanceId) => { + const raw = String(instanceId); + const driverKind = ProviderDriverKind.make( + raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw, + ); + return Effect.succeed({ + instanceId, + driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: + driverKind === ProviderDriverKind.make("codex") + ? "codex:home:/shared-codex" + : `${driverKind}:instance:${instanceId}`, + }, + }); + }, rollbackConversation: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -221,10 +327,28 @@ describe("ProviderCommandReactor", () => { Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); + const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + ); const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), - Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), + Layer.provideMerge( + Layer.mock(GitWorkflowService)({ + renameBranch, + } satisfies Partial), + ), + Layer.provideMerge( + Layer.succeed(VcsStatusBroadcaster, { + getStatus: () => Effect.die("getStatus should not be called in this test"), + refreshLocalStatus: () => + Effect.die("refreshLocalStatus should not be called in this test"), + refreshStatus, + streamStatus: () => Stream.die("streamStatus should not be called in this test"), + }), + ), Layer.provideMerge( Layer.mock(TextGeneration, { generateBranchName, @@ -235,9 +359,10 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(layer); + runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); @@ -272,6 +397,7 @@ describe("ProviderCommandReactor", () => { return { engine, + readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), startSession, sendTurn, interruptTurn, @@ -279,8 +405,10 @@ describe("ProviderCommandReactor", () => { respondToUserInput, stopSession, renameBranch, + refreshStatus, generateBranchName, generateThreadTitle, + runtimeSessions, stateDir, drain, }; @@ -288,7 +416,7 @@ describe("ProviderCommandReactor", () => { it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -313,13 +441,13 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "approval-required", }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("approval-required"); @@ -327,7 +455,7 @@ describe("ProviderCommandReactor", () => { it("generates a thread title on the first turn", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Please investigate reconnect failures after restar..."; harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); @@ -364,20 +492,20 @@ describe("ProviderCommandReactor", () => { }); await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); return ( readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === "Generated title" ); }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Generated title"); }); it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Please investigate reconnect failures after restar..."; await Effect.runPromise( @@ -410,14 +538,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.generateThreadTitle).not.toHaveBeenCalled(); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Keep this custom title"); }); it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Fix reconnect spinner on resume"; harness.generateThreadTitle.mockReturnValue( Effect.succeed({ @@ -454,21 +582,21 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); return ( readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === "Reconnect spinner resume bug" ); }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Reconnect spinner resume bug"); }); it("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -513,14 +641,16 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.refreshStatus.mock.calls.length === 1); expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ message: "Add a safer reconnect backoff.", }); + expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -533,14 +663,10 @@ describe("ProviderCommandReactor", () => { text: "hello fast mode", attachments: [], }, - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -550,33 +676,28 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), }); }); it("forwards claude effort options through session start and turn send", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, }); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -589,13 +710,11 @@ describe("ProviderCommandReactor", () => { text: "hello with effort", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -605,31 +724,30 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), }); }); it("forwards claude fast mode options through session start and turn send", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, }); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -642,13 +760,11 @@ describe("ProviderCommandReactor", () => { text: "hello with fast mode", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -658,29 +774,25 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), }); }); it("forwards plan interaction mode to the provider turn request", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -716,11 +828,62 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects a first turn when requested provider conflicts with the thread model", async () => { + it("preserves the active session model when in-session model switching is unsupported", async () => { + const harness = await createHarness({ sessionModelSwitch: "unsupported" }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + }); + }); + + it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, + threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, }); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -734,7 +897,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -743,42 +906,38 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session).toBeNull(); + expect(thread?.session?.providerName).toBe("claudeAgent"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("claudeAgent")); expect( thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - summary: "Provider turn start failed", - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + ).toBeUndefined(); }); - it("preserves the active session model when in-session model switching is unsupported", async () => { - const harness = await createHarness({ sessionModelSwitch: "unsupported" }); - const now = new Date().toISOString(); + it("reuses the same provider session when runtime mode is unchanged", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unsupported-1"), + commandId: CommandId.make("cmd-turn-start-unchanged-1"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-unsupported-1"), + messageId: asMessageId("user-message-unchanged-1"), role: "user", text: "first", attachments: [], @@ -789,15 +948,16 @@ describe("ProviderCommandReactor", () => { }), ); + await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unsupported-2"), + commandId: CommandId.make("cmd-turn-start-unchanged-2"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-unsupported-2"), + messageId: asMessageId("user-message-unchanged-2"), role: "user", text: "second", attachments: [], @@ -809,67 +969,153 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - - expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - }); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); }); - it("reuses the same provider session when runtime mode is unchanged", async () => { + it("restarts an existing Codex thread on a compatible requested instance", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unchanged-1"), + commandId: CommandId.make("cmd-turn-start-compatible-codex-1"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-unchanged-1"), + messageId: asMessageId("user-message-compatible-codex-1"), role: "user", text: "first", attachments: [], }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unchanged-2"), + commandId: CommandId.make("cmd-turn-start-compatible-codex-2"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-unchanged-2"), + messageId: asMessageId("user-message-compatible-codex-2"), role: "user", text: "second", attachments: [], }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex_work"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.startSession).toHaveBeenCalledTimes(2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex_work"), + resumeCursor: { opaque: "resume-1" }, + }); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + }); + + it("restarts the provider session when the thread workspace changes", async () => { + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-workspace-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-workspace-1"), + role: "user", + text: "first in project root", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: "/tmp/provider-project", + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-change"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-workspace-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-workspace-2"), + role: "user", + text: "second in worktree", + attachments: [], + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); + await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project-worktree", + resumeCursor: { opaque: "resume-1" }, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + runtimeMode: "approval-required", + }); }); it("restarts claude sessions when claude effort changes", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, }); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -882,13 +1128,11 @@ describe("ProviderCommandReactor", () => { text: "first claude turn", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "medium", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "medium" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -909,13 +1153,11 @@ describe("ProviderCommandReactor", () => { text: "second claude turn", attachments: [], }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -926,19 +1168,17 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ resumeCursor: { opaque: "resume-1" }, - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), }); }); it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -981,7 +1221,7 @@ describe("ProviderCommandReactor", () => { ); await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); return thread?.runtimeMode === "approval-required"; }); @@ -1015,7 +1255,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.make("thread-1"), }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("approval-required"); @@ -1023,9 +1263,12 @@ describe("ProviderCommandReactor", () => { it("does not inject derived model options when restarting claude on runtime mode changes", async () => { const harness = await createHarness({ - threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + threadModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, }); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1059,16 +1302,81 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, runtimeMode: "approval-required", }); }); + it("does not stop the active session when restart fails before rebind", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-restart-failure-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-restart-failure-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail("simulated restart failure") as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), + threadId: ThreadId.make("thread-1"), + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return thread?.runtimeMode === "approval-required"; + }); + await waitFor(() => harness.startSession.mock.calls.length === 2); + await harness.drain(); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(1); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.runtimeMode).toBe("full-access"); + }); + it("rejects provider changes after a thread is already bound to a session provider", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1102,7 +1410,7 @@ describe("ProviderCommandReactor", () => { attachments: [], }, modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1112,7 +1420,7 @@ describe("ProviderCommandReactor", () => { ); await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); return ( thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? @@ -1124,7 +1432,7 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.providerName).toBe("codex"); @@ -1138,16 +1446,25 @@ describe("ProviderCommandReactor", () => { }); }); - it("does not stop the active session when restart fails before rebind", async () => { + it("rejects cross-driver provider changes after the existing thread session has stopped", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stopped-provider-switch"), threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", + session: { + threadId: ThreadId.make("thread-1"), + status: "stopped", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, createdAt: now, }), ); @@ -1155,57 +1472,49 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-restart-failure-1"), + commandId: CommandId.make("cmd-turn-start-stopped-provider-switch"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-restart-failure-1"), + messageId: asMessageId("user-message-stopped-provider-switch"), role: "user", - text: "first", + text: "continue with claude", attachments: [], }, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - createdAt: now, - }), - ); - - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - harness.startSession.mockImplementationOnce( - (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), - threadId: ThreadId.make("thread-1"), runtimeMode: "approval-required", createdAt: now, }), ); await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return thread?.runtimeMode === "approval-required"; + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); }); - await waitFor(() => harness.startSession.mock.calls.length === 2); - await harness.drain(); - - expect(harness.stopSession.mock.calls.length).toBe(0); - expect(harness.sendTurn.mock.calls.length).toBe(1); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + expect(harness.startSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(0); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.runtimeMode).toBe("full-access"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + }, + }); }); it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1241,9 +1550,135 @@ describe("ProviderCommandReactor", () => { }); }); + it("starts a fresh session when only projected session state exists", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stale"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-stale"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-stale"), + role: "user", + text: "resume codex", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + }); + }); + + it("rejects active runtime sessions that are missing provider instance ids", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-missing-instance"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + harness.runtimeSessions.push({ + provider: ProviderDriverKind.make("codex"), + status: "ready", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project", + resumeCursor: { opaque: "resume-without-instance" }, + createdAt: now, + updatedAt: now, + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-instance"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-instance"), + role: "user", + text: "resume codex", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + expect(harness.startSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(0); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("without a provider instance id"), + }, + }); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1284,7 +1719,7 @@ describe("ProviderCommandReactor", () => { it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1329,11 +1764,11 @@ describe("ProviderCommandReactor", () => { it("surfaces stale provider approval request failures without faking approval resolution", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), method: "session/request_permission", detail: "Unknown pending permission request: approval-request-1", }), @@ -1391,7 +1826,7 @@ describe("ProviderCommandReactor", () => { ); await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); if (!thread) return false; return thread.activities.some( @@ -1399,7 +1834,7 @@ describe("ProviderCommandReactor", () => { ); }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread).toBeDefined(); @@ -1424,11 +1859,11 @@ describe("ProviderCommandReactor", () => { it("surfaces stale provider user-input failures without faking user-input resolution", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.respondToUserInput.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), method: "item/tool/respondToUserInput", detail: "Unknown pending user-input request: user-input-request-1", }), @@ -1500,7 +1935,7 @@ describe("ProviderCommandReactor", () => { ); await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); if (!thread) return false; return thread.activities.some( @@ -1508,7 +1943,7 @@ describe("ProviderCommandReactor", () => { ); }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread).toBeDefined(); @@ -1533,7 +1968,7 @@ describe("ProviderCommandReactor", () => { it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1544,6 +1979,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.make("thread-1"), status: "ready", providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -1563,11 +1999,12 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.stopSession.mock.calls.length === 1); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session).not.toBeNull(); expect(thread?.session?.status).toBe("stopped"); expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a1a69f0efaa..8b71a976808 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,28 +4,43 @@ import { EventId, type ModelSelection, type OrchestrationEvent, - ProviderKind, + ProviderDriverKind, + type ProjectId, type OrchestrationSession, ThreadId, type ProviderSession, type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; +import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; -import { TextGeneration } from "../../git/Services/TextGeneration.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import type { ProviderServiceError } from "../../provider/Errors.ts"; +import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); +const isProviderDriverKind = Schema.is(ProviderDriverKind); type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -72,10 +87,23 @@ const serverCommandId = (tag: string): CommandId => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); const DEFAULT_THREAD_TITLE = "New thread"; +export function providerErrorLabel(value: string | undefined): string { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : "unknown"; +} + +export function providerErrorLabelFromInstanceHint(input: { + readonly instanceId?: string | undefined; + readonly modelSelectionInstanceId?: string | undefined; + readonly sessionProvider?: string | undefined; +}): string { + return providerErrorLabel( + input.instanceId ?? input.modelSelectionInstanceId ?? input.sessionProvider, + ); +} + function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { const trimmedCurrentTitle = currentTitle.trim(); if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { @@ -88,9 +116,16 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea : false; } +function findProviderAdapterRequestError( + cause: Cause.Cause, +): ProviderAdapterRequestError | undefined { + const failReason = cause.reasons.find(Cause.isFailReason); + return isProviderAdapterRequestError(failReason?.error) ? failReason.error : undefined; +} + function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { - const error = Cause.squash(cause); - if (Schema.is(ProviderAdapterRequestError)(error)) { + const error = findProviderAdapterRequestError(cause); + if (error) { const detail = error.detail.toLowerCase(); return ( detail.includes("unknown pending approval request") || @@ -105,8 +140,8 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { - const error = Cause.squash(cause); - if (Schema.is(ProviderAdapterRequestError)(error)) { + const error = findProviderAdapterRequestError(cause); + if (error) { return error.detail.toLowerCase().includes("unknown pending user-input request"); } return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request"); @@ -119,10 +154,6 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function isTemporaryWorktreeBranch(branch: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); -} - function buildGeneratedWorktreeBranchName(raw: string): string { const normalized = raw .trim() @@ -148,8 +179,10 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; - const git = yield* GitCore; + const gitWorkflow = yield* GitWorkflowService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ @@ -200,6 +233,17 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); + const formatFailureDetail = (cause: Cause.Cause): string => { + const failReason = cause.reasons.find(Cause.isFailReason); + const providerError = isProviderAdapterRequestError(failReason?.error) + ? failReason.error + : undefined; + if (providerError) { + return providerError.detail; + } + return Cause.pretty(cause); + }; + const setThreadSession = (input: { readonly threadId: ThreadId; readonly session: OrchestrationSession; @@ -213,9 +257,39 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); - const resolveThread = Effect.fn("resolveThread")(function* (threadId: ThreadId) { - const readModel = yield* orchestrationEngine.getReadModel(); - return readModel.threads.find((entry) => entry.id === threadId); + const setThreadSessionErrorOnTurnStartFailure = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly detail: string; + readonly createdAt: string; + }) { + const thread = yield* resolveThread(input.threadId); + const session = thread?.session; + if (!session) { + return; + } + yield* setThreadSession({ + threadId: input.threadId, + session: { + ...session, + status: session.status === "stopped" ? "stopped" : "ready", + activeTurnId: null, + lastError: input.detail, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + + const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) { + return yield* projectionSnapshotQuery + .getProjectShellById(projectId) + .pipe(Effect.map(Option.getOrUndefined)); + }); + + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { + return yield* projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(Effect.map(Option.getOrUndefined)); }); const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* ( @@ -225,49 +299,115 @@ const make = Effect.gen(function* () { readonly modelSelection?: ModelSelection; }, ) { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === threadId); + const thread = yield* resolveThread(threadId); if (!thread) { return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); } const desiredRuntimeMode = thread.runtimeMode; - const currentProvider: ProviderKind | undefined = Schema.is(ProviderKind)( - thread.session?.providerName, - ) - ? thread.session.providerName - : undefined; const requestedModelSelection = options?.modelSelection; - const threadProvider: ProviderKind = currentProvider ?? thread.modelSelection.provider; + const resolveActiveSession = (threadId: ThreadId) => + providerService + .listSessions() + .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); + + const activeSession = yield* resolveActiveSession(threadId); + const activeThreadSession = + thread.session !== null && thread.session.status !== "stopped" && activeSession + ? thread.session + : null; if ( - requestedModelSelection !== undefined && - requestedModelSelection.provider !== threadProvider + activeThreadSession !== null && + activeSession !== undefined && + (activeThreadSession.providerInstanceId === undefined || + activeSession.providerInstanceId === undefined) ) { return yield* new ProviderAdapterRequestError({ - provider: threadProvider, + provider: providerErrorLabel(activeThreadSession.providerName ?? undefined), method: "thread.turn.start", - detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, + detail: `Thread '${threadId}' has an active provider session without a provider instance id.`, }); } - const preferredProvider: ProviderKind = currentProvider ?? threadProvider; + const currentInstanceId = + activeThreadSession !== null && + activeSession !== undefined && + activeSession.providerInstanceId !== undefined + ? activeSession.providerInstanceId + : thread.modelSelection.instanceId; const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; + const desiredInstanceId = desiredModelSelection.instanceId; + const currentInfo = yield* providerService.getInstanceInfo(currentInstanceId).pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(currentInstanceId), + modelSelectionInstanceId: String(thread.modelSelection.instanceId), + sessionProvider: thread.session?.providerName ?? undefined, + }), + method: "thread.turn.start", + detail: `Thread '${threadId}' references unknown provider instance '${currentInstanceId}'. The instance is not configured in this build.`, + }), + ), + ); + const desiredInfo = yield* providerService.getInstanceInfo(desiredInstanceId).pipe( + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(desiredModelSelection.instanceId), + }), + method: "thread.turn.start", + detail: `Requested provider instance '${desiredInstanceId}' is not configured in this build.`, + }), + ), + ); + const desiredDriverKind = desiredInfo.driverKind; + if (!isProviderDriverKind(desiredDriverKind)) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(String(desiredDriverKind)), + method: "thread.turn.start", + detail: `Requested provider instance '${desiredInstanceId}' uses unknown provider driver '${desiredDriverKind}'. The driver is not installed in this build.`, + }); + } + const preferredProvider: ProviderDriverKind = desiredDriverKind; + if ( + thread.session !== null && + requestedModelSelection !== undefined && + requestedModelSelection.instanceId !== currentInstanceId + ) { + if (currentInfo.driverKind !== desiredInfo.driverKind) { + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: `Thread '${threadId}' is bound to driver '${currentInfo.driverKind}' and cannot switch to '${desiredInfo.driverKind}'.`, + }); + } + if ( + currentInfo.continuationIdentity.continuationKey !== + desiredInfo.continuationIdentity.continuationKey + ) { + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: `Thread '${threadId}' cannot switch from instance '${currentInstanceId}' to '${desiredInstanceId}' because their provider resume state is incompatible.`, + }); + } + } + const project = yield* resolveProject(thread.projectId); const effectiveCwd = resolveThreadWorkspaceCwd({ thread, - projects: readModel.projects, + projects: project ? [project] : [], }); - const resolveActiveSession = (threadId: ThreadId) => - providerService - .listSessions() - .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); - const startProviderSession = (input?: { readonly resumeCursor?: unknown; - readonly provider?: ProviderKind; + readonly provider?: ProviderDriverKind; }) => providerService.startSession(threadId, { threadId, ...(preferredProvider ? { provider: preferredProvider } : {}), + providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), @@ -275,66 +415,79 @@ const make = Effect.gen(function* () { }); const bindSessionToThread = (session: ProviderSession) => - setThreadSession({ - threadId, - session: { + Effect.gen(function* () { + if (session.providerInstanceId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(session.provider), + method: "thread.turn.start", + detail: `Provider session '${session.threadId}' started without a provider instance id.`, + }); + } + yield* setThreadSession({ threadId, - status: mapProviderSessionStatusToOrchestrationStatus(session.status), - providerName: session.provider, - runtimeMode: desiredRuntimeMode, - // Provider turn ids are not orchestration turn ids. - activeTurnId: null, - lastError: session.lastError ?? null, - updatedAt: session.updatedAt, - }, - createdAt, + session: { + threadId, + status: mapProviderSessionStatusToOrchestrationStatus(session.status), + providerName: session.provider, + providerInstanceId: session.providerInstanceId, + runtimeMode: desiredRuntimeMode, + // Provider turn ids are not orchestration turn ids. + activeTurnId: null, + lastError: session.lastError ?? null, + updatedAt: session.updatedAt, + }, + createdAt, + }); }); const existingSessionThreadId = - thread.session && thread.session.status !== "stopped" ? thread.id : null; + thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; - const providerChanged = - requestedModelSelection !== undefined && - requestedModelSelection.provider !== currentProvider; - const activeSession = yield* resolveActiveSession(existingSessionThreadId); - const sessionModelSwitch = - currentProvider === undefined - ? "in-session" - : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; + const cwdChanged = effectiveCwd !== activeSession?.cwd; + const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) + .sessionModelSwitch; const modelChanged = requestedModelSelection !== undefined && requestedModelSelection.model !== activeSession?.model; - const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; + const instanceChanged = + requestedModelSelection !== undefined && + activeSession?.providerInstanceId !== requestedModelSelection.instanceId; + const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; const previousModelSelection = threadModelSelections.get(threadId); const shouldRestartForModelSelectionChange = - currentProvider === "claudeAgent" && + preferredProvider === "claudeAgent" && requestedModelSelection !== undefined && !Equal.equals(previousModelSelection, requestedModelSelection); if ( !runtimeModeChanged && - !providerChanged && + !cwdChanged && + !instanceChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { return existingSessionThreadId; } - const resumeCursor = - providerChanged || shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const resumeCursor = shouldRestartForModelChange + ? undefined + : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, - currentProvider, - desiredProvider: desiredModelSelection.provider, + currentProvider: activeSession?.provider, + currentInstanceId, + desiredInstanceId, + desiredProvider: desiredModelSelection.instanceId, currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, - providerChanged, + previousCwd: activeSession?.cwd, + desiredCwd: effectiveCwd, + cwdChanged, modelChanged, + instanceChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, hasResumeCursor: resumeCursor !== undefined, @@ -348,6 +501,7 @@ const make = Effect.gen(function* () { restartedSessionThreadId: restartedSession.threadId, provider: restartedSession.provider, runtimeMode: restartedSession.runtimeMode, + cwd: restartedSession.cwd, }); yield* bindSessionToThread(restartedSession); return restartedSession.threadId; @@ -358,7 +512,7 @@ const make = Effect.gen(function* () { return startedSession.threadId; }); - const sendTurnForThread = Effect.fn("sendTurnForThread")(function* (input: { + const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -368,7 +522,9 @@ const make = Effect.gen(function* () { }) { const thread = yield* resolveThread(input.threadId); if (!thread) { - return; + return yield* Effect.die( + new Error(`Thread '${input.threadId}' was not found in read model.`), + ); } yield* ensureSessionForThread( input.threadId, @@ -388,11 +544,18 @@ const make = Effect.gen(function* () { const sessionModelSwitch = activeSession === undefined ? "in-session" - : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; + : activeSession.providerInstanceId === undefined + ? yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(activeSession.provider), + method: "thread.turn.start", + detail: `Active provider session '${activeSession.threadId}' is missing a provider instance id.`, + }) + : (yield* providerService.getCapabilities(activeSession.providerInstanceId)) + .sessionModelSwitch; const requestedModelSelection = input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; const modelForTurn = - sessionModelSwitch === "unsupported" + sessionModelSwitch === "unsupported" && input.modelSelection === undefined ? activeSession?.model !== undefined ? { ...requestedModelSelection, @@ -401,13 +564,13 @@ const make = Effect.gen(function* () { : requestedModelSelection : input.modelSelection; - yield* providerService.sendTurn({ + return { threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), - }); + }; }); const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fn( @@ -444,7 +607,7 @@ const make = Effect.gen(function* () { const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); if (targetBranch === oldBranch) return; - const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); + const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: serverCommandId("worktree-branch-rename"), @@ -452,6 +615,7 @@ const make = Effect.gen(function* () { branch: renamed.branch, worktreePath: cwd, }); + yield* vcsStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( Effect.catchCause((cause) => Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { @@ -538,10 +702,11 @@ const make = Effect.gen(function* () { const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { + const project = yield* resolveProject(thread.projectId); const generationCwd = resolveThreadWorkspaceCwd({ thread, - projects: (yield* orchestrationEngine.getReadModel()).projects, + projects: project ? [project] : [], }) ?? process.cwd(); const generationInput = { messageText: message.text, @@ -565,7 +730,43 @@ const make = Effect.gen(function* () { } } - yield* sendTurnForThread({ + const handleTurnStartFailure = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const detail = formatFailureDetail(cause); + return setThreadSessionErrorOnTurnStartFailure({ + threadId: event.payload.threadId, + detail, + createdAt: event.payload.createdAt, + }).pipe( + Effect.flatMap(() => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + }), + ), + Effect.asVoid, + ); + }; + + const recoverTurnStartFailure = (cause: Cause.Cause) => + handleTurnStartFailure(cause).pipe( + Effect.catchCause((recoveryCause) => + Effect.logWarning("provider command reactor failed to recover turn start failure", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(recoveryCause), + originalCause: Cause.pretty(cause), + }), + ), + ); + + const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), @@ -575,17 +776,17 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.catchCause((cause) => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", - detail: Cause.pretty(cause), - turnId: null, - createdAt: event.payload.createdAt, - }), - ), + Effect.map(Option.some), + Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))), ); + + if (Option.isNone(sendTurnRequest)) { + return; + } + + yield* providerService + .sendTurn(sendTurnRequest.value) + .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( @@ -639,20 +840,16 @@ const make = Effect.gen(function* () { }) .pipe( Effect.catchCause((cause) => - Effect.gen(function* () { - yield* appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.approval.respond.failed", - summary: "Provider approval response failed", - detail: isUnknownPendingApprovalRequestError(cause) - ? stalePendingRequestDetail("approval", event.payload.requestId) - : Cause.pretty(cause), - turnId: null, - createdAt: event.payload.createdAt, - requestId: event.payload.requestId, - }); - - if (!isUnknownPendingApprovalRequestError(cause)) return; + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + detail: isUnknownPendingApprovalRequestError(cause) + ? stalePendingRequestDetail("approval", event.payload.requestId) + : Cause.pretty(cause), + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, }), ), ); @@ -722,6 +919,9 @@ const make = Effect.gen(function* () { threadId: thread.id, status: "stopped", providerName: thread.session?.providerName ?? null, + ...(thread.session?.providerInstanceId !== undefined + ? { providerInstanceId: thread.session.providerInstanceId } + : {}), runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, lastError: thread.session?.lastError ?? null, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 2a330a36b53..3b2411cba2a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1,11 +1,14 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { +import { OrchestrationReadModel, + ProviderDriverKind, ProviderRuntimeEvent, ProviderSession, + ProviderInstanceId, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -19,7 +22,14 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -34,11 +44,9 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; -import { - OrchestrationEngineService, - type OrchestrationEngineShape, -} from "../Services/OrchestrationEngine.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -98,6 +106,19 @@ function createProviderServiceHarness() { stopSession: () => unsupported(), listSessions: () => Effect.succeed([...runtimeSessions]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => { + const driverKind = ProviderDriverKind.make(String(instanceId)); + return Effect.succeed({ + instanceId, + driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: `${driverKind}:instance:${instanceId}`, + }, + }); + }, rollbackConversation: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -139,38 +160,38 @@ function createProviderServiceHarness() { }; } +type ProviderRuntimeTestReadModel = OrchestrationReadModel; +type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number]; +type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number]; +type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number]; +type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number]; +type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number]; + async function waitForThread( - engine: OrchestrationEngineShape, + readModel: () => Promise, predicate: (thread: ProviderRuntimeTestThread) => boolean, timeoutMs = 2000, threadId: ThreadId = asThreadId("thread-1"), ) { - const deadline = Date.now() + timeoutMs; + const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; const poll = async (): Promise => { - const readModel = await Effect.runPromise(engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === threadId); + const snapshot = await readModel(); + const thread = snapshot.threads.find((entry) => entry.id === threadId); if (thread && predicate(thread)) { return thread; } - if (Date.now() >= deadline) { + if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) { throw new Error("Timed out waiting for thread state"); } - await new Promise((resolve) => setTimeout(resolve, 10)); + await Effect.runPromise(Effect.yieldNow); return poll(); }; return poll(); } -type ProviderRuntimeTestReadModel = OrchestrationReadModel; -type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number]; -type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number]; -type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number]; -type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number]; -type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number]; - describe("ProviderRuntimeIngestion", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | ProviderRuntimeIngestionService, + OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -208,8 +229,13 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); + const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + ); const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), @@ -218,12 +244,13 @@ describe("ProviderRuntimeIngestion", () => { ); runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( engine.dispatch({ type: "project.create", @@ -232,7 +259,7 @@ describe("ProviderRuntimeIngestion", () => { title: "Provider Project", workspaceRoot, defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, createdAt, @@ -246,7 +273,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -274,7 +301,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); provider.setSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", runtimeMode: "approval-required", threadId: ThreadId.make("thread-1"), @@ -284,6 +311,7 @@ describe("ProviderRuntimeIngestion", () => { return { engine, + readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, drain, @@ -292,28 +320,28 @@ describe("ProviderRuntimeIngestion", () => { it("maps turn started/completed events into thread session updates", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: now, turnId: asTurnId("turn-1"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-1", ); harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", turnId: asTurnId("turn-1"), payload: { state: "failed", @@ -322,7 +350,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "error" && entry.session?.activeTurnId === null && @@ -334,12 +362,12 @@ describe("ProviderRuntimeIngestion", () => { it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); - const waitingAt = new Date().toISOString(); + const waitingAt = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-waiting"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), createdAt: waitingAt, payload: { @@ -349,7 +377,7 @@ describe("ProviderRuntimeIngestion", () => { }); let thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === null, ); expect(thread.session?.status).toBe("running"); @@ -358,9 +386,9 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-error"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", payload: { state: "error", reason: "provider crashed", @@ -368,7 +396,7 @@ describe("ProviderRuntimeIngestion", () => { }); thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "error" && entry.session?.activeTurnId === null && @@ -380,16 +408,16 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-stopped"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", payload: { state: "stopped", }, }); thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "stopped" && entry.session?.activeTurnId === null && @@ -401,16 +429,16 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "session.state.changed", eventId: asEventId("evt-session-state-ready"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", payload: { state: "ready", }, }); thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "ready" && entry.session?.activeTurnId === null && @@ -422,19 +450,19 @@ describe("ProviderRuntimeIngestion", () => { it("does not clear active turn when session/thread started arrives mid-turn", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-midturn-lifecycle"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-midturn-lifecycle"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-midturn-lifecycle", @@ -443,20 +471,20 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.started", eventId: asEventId("evt-thread-started-midturn-lifecycle"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), }); harness.emit({ type: "session.started", eventId: asEventId("evt-session-started-midturn-lifecycle"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), }); await harness.drain(); - const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); + const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle"); @@ -464,22 +492,22 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-midturn-lifecycle"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-midturn-lifecycle"), status: "completed", }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, ); }); it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { const harness = await createHarness(); - const seededAt = new Date().toISOString(); + const seededAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -502,14 +530,14 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-claude-placeholder"), - provider: "claudeAgent", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-claude-placeholder"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-claude-placeholder", @@ -518,34 +546,34 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-claude-placeholder"), - provider: "claudeAgent", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-claude-placeholder"), status: "completed", }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, ); }); it("ignores auxiliary turn completions from a different provider thread", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-primary"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-primary"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary", ); @@ -553,15 +581,15 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-aux"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-aux"), status: "completed", }); await harness.drain(); - const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); + const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-primary"); @@ -569,34 +597,34 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-primary"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-primary"), status: "completed", }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, ); }); it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-guarded"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-guarded-main"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-guarded-main", @@ -605,15 +633,15 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-other"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-guarded-other"), status: "completed", }); await harness.drain(); - const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); + const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main"); @@ -621,27 +649,27 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-main"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-guarded-main"), status: "completed", }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, ); }); it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-2"), @@ -654,7 +682,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-2"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-2"), @@ -667,7 +695,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-2"), @@ -678,7 +706,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-1" && !message.streaming, @@ -693,12 +721,12 @@ describe("ProviderRuntimeIngestion", () => { it("uses assistant item completion detail when no assistant deltas were streamed", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "item.completed", eventId: asEventId("evt-assistant-item-completed-no-delta"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-no-delta"), @@ -710,7 +738,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-no-delta" && !message.streaming, @@ -723,14 +751,154 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("preserves completed tool metadata on projected tool activities", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-tool-completed-with-data"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-tool-completed"), + itemId: asItemId("item-tool-completed"), + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Read file", + data: { + toolCallId: "tool-read-1", + kind: "read", + rawOutput: { + content: 'import * as Effect from "effect/Effect"\n', + }, + }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-completed-with-data", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-tool-completed-with-data", + ); + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + const data = + payload?.data && typeof payload.data === "object" + ? (payload.data as Record) + : undefined; + const rawOutput = + data?.rawOutput && typeof data.rawOutput === "object" + ? (data.rawOutput as Record) + : undefined; + + expect(activity?.kind).toBe("tool.completed"); + expect(activity?.summary).toBe("Read file"); + expect(payload?.itemType).toBe("dynamic_tool_call"); + expect(payload?.detail).toBeUndefined(); + expect(data?.toolCallId).toBe("tool-read-1"); + expect(data?.kind).toBe("read"); + expect(rawOutput?.content).toBe('import * as Effect from "effect/Effect"\n'); + }); + + it("normalizes command execution activities to ran-command summaries", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-command-completed"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-command-completed"), + itemId: asItemId("item-command-completed"), + payload: { + itemType: "command_execution", + status: "completed", + title: "Ran command", + detail: "bun run lint", + data: { + toolCallId: "tool-command-1", + kind: "execute", + command: "bun run lint", + }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-completed", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-command-completed", + ); + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + + expect(activity?.summary).toBe("Ran command"); + expect(payload?.detail).toBe("bun run lint"); + }); + + it("uses structured read-file paths when available", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-read-path-completed"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-read-path"), + itemId: asItemId("item-read-path"), + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Read file", + detail: "/tmp/app.ts", + data: { + toolCallId: "tool-read-path-1", + kind: "read", + locations: [{ path: "/tmp/app.ts" }], + }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-read-path-completed", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-read-path-completed", + ); + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + + expect(activity?.summary).toBe("Read file"); + expect(payload?.detail).toBe("/tmp/app.ts"); + }); + it("projects completed plan items into first-class proposed plans", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-item-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-final"), @@ -739,7 +907,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => proposedPlan.id === "plan:thread-1:turn:turn-plan-final", @@ -759,7 +927,7 @@ describe("ProviderRuntimeIngestion", () => { const targetThreadId = asThreadId("thread-implement"); const sourceTurnId = asTurnId("turn-plan-source"); const targetTurnId = asTurnId("turn-plan-implement"); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -769,7 +937,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Source", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "plan", @@ -804,7 +972,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Target", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -832,7 +1000,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", runtimeMode: "approval-required", threadId: targetThreadId, @@ -844,7 +1012,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-source-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: sourceThreadId, turnId: sourceTurnId, @@ -854,7 +1022,7 @@ describe("ProviderRuntimeIngestion", () => { }); const sourceThreadWithPlan = await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => @@ -890,12 +1058,12 @@ describe("ProviderRuntimeIngestion", () => { }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", }), ); const sourceThreadBeforeStart = await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => @@ -914,14 +1082,14 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-plan-target-started"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: targetThreadId, turnId: targetTurnId, }); const sourceThreadAfterStart = await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => @@ -946,7 +1114,7 @@ describe("ProviderRuntimeIngestion", () => { const sourceTurnId = asTurnId("turn-plan-source"); const activeTurnId = asTurnId("turn-already-running"); const staleTurnId = asTurnId("turn-stale-start"); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -956,7 +1124,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Source", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "plan", @@ -984,7 +1152,7 @@ describe("ProviderRuntimeIngestion", () => { }), ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "running", runtimeMode: "approval-required", threadId: targetThreadId, @@ -996,14 +1164,14 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-already-running"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: targetThreadId, turnId: activeTurnId, }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === activeTurnId, 2_000, @@ -1013,7 +1181,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-source-completed-guarded"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: sourceThreadId, turnId: sourceTurnId, @@ -1023,7 +1191,7 @@ describe("ProviderRuntimeIngestion", () => { }); const sourceThreadWithPlan = await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => @@ -1059,22 +1227,22 @@ describe("ProviderRuntimeIngestion", () => { }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", }), ); harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-stale-plan-implementation"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: targetThreadId, turnId: staleTurnId, }); await harness.drain(); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const sourceThreadAfterRejectedStart = readModel.threads.find( (entry) => entry.id === sourceThreadId, ); @@ -1099,7 +1267,7 @@ describe("ProviderRuntimeIngestion", () => { const sourceTurnId = asTurnId("turn-plan-source"); const expectedTurnId = asTurnId("turn-plan-implement"); const replayedTurnId = asTurnId("turn-replayed"); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1109,7 +1277,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Source", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: "plan", @@ -1144,7 +1312,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Plan Target", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1175,7 +1343,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-plan-source-completed-unrelated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt, threadId: sourceThreadId, turnId: sourceTurnId, @@ -1185,7 +1353,7 @@ describe("ProviderRuntimeIngestion", () => { }); const sourceThreadWithPlan = await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => @@ -1221,12 +1389,12 @@ describe("ProviderRuntimeIngestion", () => { }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", }), ); harness.setProviderSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "running", runtimeMode: "approval-required", threadId: targetThreadId, @@ -1238,15 +1406,15 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-unrelated-plan-implementation"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: targetThreadId, turnId: replayedTurnId, }); await harness.drain(); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const sourceThreadAfterUnrelatedStart = readModel.threads.find( (entry) => entry.id === sourceThreadId, ); @@ -1260,19 +1428,19 @@ describe("ProviderRuntimeIngestion", () => { it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-plan-buffer"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer", ); @@ -1280,7 +1448,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-1"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), @@ -1291,7 +1459,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-2"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), @@ -1302,7 +1470,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-plan-buffer"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), @@ -1311,7 +1479,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => proposedPlan.id === "plan:thread-1:turn:turn-plan-buffer", @@ -1326,18 +1494,18 @@ describe("ProviderRuntimeIngestion", () => { it("buffers assistant deltas by default until completion", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffered"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-buffered", ); @@ -1345,7 +1513,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffered"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered"), @@ -1357,7 +1525,7 @@ describe("ProviderRuntimeIngestion", () => { }); await harness.drain(); - const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); + const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect( midThread?.messages.some( @@ -1368,7 +1536,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-buffered"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffered"), @@ -1379,7 +1547,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-buffered" && !message.streaming, @@ -1392,9 +1560,435 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("flushes and completes buffered assistant text when an approval request opens", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-request-flush"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-flush"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-request-flush", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-request-flush"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-flush"), + itemId: asItemId("item-buffered-request-flush"), + payload: { + streamKind: "assistant_text", + delta: "visible before approval", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-buffered-request-flush"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-flush"), + requestId: ApprovalRequestId.make("req-buffered-request-flush"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-request-flush" && + !message.streaming && + message.text === "visible before approval", + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-flush", + ); + expect(message?.streaming).toBe(false); + }); + + it("flushes and completes buffered assistant text when user input is requested", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-user-input-flush"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-user-input-flush"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-user-input-flush", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-user-input-flush"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-user-input-flush"), + itemId: asItemId("item-buffered-user-input-flush"), + payload: { + streamKind: "assistant_text", + delta: "visible before user input", + }, + }); + harness.emit({ + type: "user-input.requested", + eventId: asEventId("evt-user-input-requested-buffered-user-input-flush"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-user-input-flush"), + requestId: ApprovalRequestId.make("req-buffered-user-input-flush"), + payload: { + questions: [ + { + id: "choice", + header: "Choice", + question: "Pick one", + options: [{ label: "A", description: "Option A" }], + }, + ], + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-user-input-flush" && + !message.streaming && + message.text === "visible before user input", + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-buffered-user-input-flush", + ); + expect(message?.streaming).toBe(false); + }); + + it("does not create assistant segments for whitespace-only buffered text at approval boundaries", async () => { + const harness = await createHarness(); + const startedAt = "2026-03-28T06:28:00.000Z"; + const pausedAt = "2026-03-28T06:28:01.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-whitespace-request"), + provider: ProviderDriverKind.make("codex"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-whitespace-request"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-whitespace-request", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-whitespace-request"), + provider: ProviderDriverKind.make("codex"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-whitespace-request"), + itemId: asItemId("item-buffered-whitespace-request"), + payload: { + streamKind: "assistant_text", + delta: "\n\n\n", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-buffered-whitespace-request"), + provider: ProviderDriverKind.make("codex"), + createdAt: pausedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-whitespace-request"), + requestId: ApprovalRequestId.make("req-buffered-whitespace-request"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested", + ), + ); + expect( + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-whitespace-request", + ), + ).toBe(false); + }); + + it("starts a new buffered assistant message segment after approval and completes without duplication", async () => { + const harness = await createHarness(); + const startedAt = "2026-03-28T06:07:00.000Z"; + const pausedAt = "2026-03-28T06:07:01.000Z"; + const resumedAt = "2026-03-28T06:07:02.000Z"; + const completedAt = "2026-03-28T06:07:03.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-request-append"), + provider: ProviderDriverKind.make("codex"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-request-append", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-request-append-initial"), + provider: ProviderDriverKind.make("codex"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + itemId: asItemId("item-buffered-request-append"), + payload: { + streamKind: "assistant_text", + delta: "first half", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-buffered-request-append"), + provider: ProviderDriverKind.make("codex"), + createdAt: pausedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + requestId: ApprovalRequestId.make("req-buffered-request-append"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-request-append" && + !message.streaming && + message.text === "first half", + ), + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-request-append-followup"), + provider: ProviderDriverKind.make("codex"), + createdAt: resumedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + itemId: asItemId("item-buffered-request-append"), + payload: { + streamKind: "assistant_text", + delta: " second half", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-buffered-request-append"), + provider: ProviderDriverKind.make("codex"), + createdAt: completedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + itemId: asItemId("item-buffered-request-append"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-request-append:segment:1" && + !message.streaming && + message.text === " second half", + ), + ); + const firstMessage = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-append", + ); + const resumedMessage = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-buffered-request-append:segment:1", + ); + expect(firstMessage?.text).toBe("first half"); + expect(firstMessage?.streaming).toBe(false); + expect(resumedMessage?.text).toBe(" second half"); + expect(resumedMessage?.streaming).toBe(false); + + const events = await Effect.runPromise( + Stream.runCollect(harness.engine.readEvents(0)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ), + ); + const assistantEvents = events.filter( + (event): event is Extract<(typeof events)[number], { type: "thread.message-sent" }> => + event.type === "thread.message-sent" && + event.payload.messageId.startsWith("assistant:item-buffered-request-append"), + ); + expect(assistantEvents).toHaveLength(4); + expect(assistantEvents[0]?.payload.streaming).toBe(true); + expect(assistantEvents[0]?.payload.text).toBe("first half"); + expect(assistantEvents[1]?.payload.streaming).toBe(false); + expect(assistantEvents[1]?.payload.text).toBe(""); + expect(assistantEvents[2]?.payload.messageId).toBe( + "assistant:item-buffered-request-append:segment:1", + ); + expect(assistantEvents[2]?.payload.streaming).toBe(true); + expect(assistantEvents[2]?.payload.text).toBe(" second half"); + expect(assistantEvents[3]?.payload.messageId).toBe( + "assistant:item-buffered-request-append:segment:1", + ); + expect(assistantEvents[3]?.payload.streaming).toBe(false); + expect(assistantEvents[3]?.payload.text).toBe(""); + }); + + it("starts a new streaming assistant message segment after approval", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const startedAt = "2026-03-28T07:00:00.000Z"; + const pausedAt = "2026-03-28T07:00:01.000Z"; + const resumedAt = "2026-03-28T07:00:02.000Z"; + const completedAt = "2026-03-28T07:00:03.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-streaming-request-segment"), + provider: ProviderDriverKind.make("codex"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-streaming-request-segment", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-streaming-request-segment-initial"), + provider: ProviderDriverKind.make("codex"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + itemId: asItemId("item-streaming-request-segment"), + payload: { + streamKind: "assistant_text", + delta: "before approval", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-streaming-request-segment"), + provider: ProviderDriverKind.make("codex"), + createdAt: pausedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + requestId: ApprovalRequestId.make("req-streaming-request-segment"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment" && + !message.streaming && + message.text === "before approval", + ), + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-streaming-request-segment-followup"), + provider: ProviderDriverKind.make("codex"), + createdAt: resumedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + itemId: asItemId("item-streaming-request-segment"), + payload: { + streamKind: "assistant_text", + delta: " after approval", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-streaming-request-segment"), + provider: ProviderDriverKind.make("codex"), + createdAt: completedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + itemId: asItemId("item-streaming-request-segment"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment:segment:1" && + !message.streaming && + message.text === " after approval", + ), + ); + expect( + thread.messages.find( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment", + )?.text, + ).toBe("before approval"); + expect( + thread.messages.find( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment:segment:1", + )?.text, + ).toBe(" after approval"); + }); + it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( harness.engine.dispatch({ @@ -1417,13 +2011,13 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-streaming-mode"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-mode"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-streaming-mode", @@ -1432,7 +2026,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-streaming-mode"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-mode"), @@ -1443,7 +2037,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const liveThread = await waitForThread(harness.engine, (entry) => + const liveThread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-streaming-mode" && @@ -1459,7 +2053,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-streaming-mode"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-streaming-mode"), @@ -1471,7 +2065,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const finalThread = await waitForThread(harness.engine, (entry) => + const finalThread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-streaming-mode" && !message.streaming, @@ -1486,19 +2080,19 @@ describe("ProviderRuntimeIngestion", () => { it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const oversizedText = "x".repeat(40_000); harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffer-spill"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffer-spill"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-buffer-spill", @@ -1507,7 +2101,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffer-spill"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffer-spill"), @@ -1520,7 +2114,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-buffer-spill"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-buffer-spill"), @@ -1531,7 +2125,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-buffer-spill" && !message.streaming, @@ -1547,19 +2141,19 @@ describe("ProviderRuntimeIngestion", () => { it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-complete-dedup", @@ -1568,7 +2162,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), @@ -1581,7 +2175,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.completed", eventId: asEventId("evt-message-completed-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), @@ -1594,7 +2188,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-for-complete-dedup"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-complete-dedup"), @@ -1604,7 +2198,7 @@ describe("ProviderRuntimeIngestion", () => { }); await waitForThread( - harness.engine, + harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null && @@ -1633,12 +2227,12 @@ describe("ProviderRuntimeIngestion", () => { it("maps canonical request events into approval activities with requestKind", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "request.opened", eventId: asEventId("evt-request-opened"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), requestId: ApprovalRequestId.make("req-open"), @@ -1651,7 +2245,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "request.resolved", eventId: asEventId("evt-request-resolved"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), requestId: ApprovalRequestId.make("req-open"), @@ -1662,7 +2256,7 @@ describe("ProviderRuntimeIngestion", () => { }); await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested", @@ -1672,7 +2266,7 @@ describe("ProviderRuntimeIngestion", () => { ), ); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread).toBeDefined(); @@ -1699,12 +2293,12 @@ describe("ProviderRuntimeIngestion", () => { it("maps runtime.error into errored session state", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-3"), @@ -1714,7 +2308,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "error" && entry.session?.activeTurnId === "turn-3" && @@ -1726,12 +2320,12 @@ describe("ProviderRuntimeIngestion", () => { it("records runtime.error activities from the typed payload message", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error-activity"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-runtime-error-activity"), @@ -1740,7 +2334,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.activities.some((activity) => activity.id === "evt-runtime-error-activity"), ); const activity = thread.activities.find( @@ -1757,12 +2351,12 @@ describe("ProviderRuntimeIngestion", () => { it("keeps the session running when a runtime.warning arrives during an active turn", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-warning-turn-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-warning"), @@ -1772,7 +2366,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.warning", eventId: asEventId("evt-warning-runtime"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-warning"), @@ -1785,7 +2379,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === "turn-warning" && @@ -1801,12 +2395,12 @@ describe("ProviderRuntimeIngestion", () => { it("maps session/thread lifecycle and item.started into session/activity projections", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "session.started", eventId: asEventId("evt-session-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), message: "session started", @@ -1814,14 +2408,14 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "thread.started", eventId: asEventId("evt-thread-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), }); harness.emit({ type: "item.started", eventId: asEventId("evt-tool-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), @@ -1834,7 +2428,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "ready" && entry.session?.activeTurnId === null && @@ -1853,12 +2447,12 @@ describe("ProviderRuntimeIngestion", () => { it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "thread.metadata.updated", eventId: asEventId("evt-thread-metadata-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -1870,7 +2464,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.plan.updated", eventId: asEventId("evt-turn-plan-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -1886,7 +2480,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "item.updated", eventId: asEventId("evt-item-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -1903,7 +2497,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.warning", eventId: asEventId("evt-runtime-warning"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -1916,7 +2510,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.diff.updated", eventId: asEventId("evt-turn-diff-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-p1"), @@ -1927,7 +2521,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.title === "Renamed by provider" && entry.activities.some( @@ -1987,12 +2581,12 @@ describe("ProviderRuntimeIngestion", () => { it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "thread.token-usage.updated", eventId: asEventId("evt-thread-token-usage-updated"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -2014,7 +2608,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated", ), @@ -2039,12 +2633,12 @@ describe("ProviderRuntimeIngestion", () => { it("projects Codex camelCase token usage payloads into normalized thread activities", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "thread.token-usage.updated", eventId: asEventId("evt-thread-token-usage-updated-camel"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -2066,7 +2660,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated", ), @@ -2092,12 +2686,12 @@ describe("ProviderRuntimeIngestion", () => { it("projects Claude usage snapshots with context window into normalized thread activities", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "thread.token-usage.updated", eventId: asEventId("evt-thread-token-usage-updated-claude-window"), - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), createdAt: now, threadId: asThreadId("thread-1"), payload: { @@ -2116,7 +2710,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated", ), @@ -2136,12 +2730,12 @@ describe("ProviderRuntimeIngestion", () => { it("projects compacted thread state into context compaction activities", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "thread.state.changed", eventId: asEventId("evt-thread-compacted"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), @@ -2151,7 +2745,7 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread(harness.engine, (entry) => + const thread = await waitForThread(harness.readModel, (entry) => entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction", ), @@ -2166,12 +2760,12 @@ describe("ProviderRuntimeIngestion", () => { it("projects Codex task lifecycle chunks into thread activities", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "task.started", eventId: asEventId("evt-task-started"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2184,7 +2778,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "task.progress", eventId: asEventId("evt-task-progress"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2198,7 +2792,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "task.completed", eventId: asEventId("evt-task-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2211,7 +2805,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.proposed.completed", eventId: asEventId("evt-task-proposed-plan-completed"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-task-1"), @@ -2221,7 +2815,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed", @@ -2269,12 +2863,12 @@ describe("ProviderRuntimeIngestion", () => { it("projects structured user input request and resolution as thread activities", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "user-input.requested", eventId: asEventId("evt-user-input-requested"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-user-input"), @@ -2299,8 +2893,8 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "user-input.resolved", eventId: asEventId("evt-user-input-resolved"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-user-input"), requestId: ApprovalRequestId.make("req-user-input-1"), @@ -2312,7 +2906,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested", @@ -2342,12 +2936,12 @@ describe("ProviderRuntimeIngestion", () => { it("continues processing runtime events after a single event handler failure", async () => { const harness = await createHarness(); - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "content.delta", eventId: asEventId("evt-invalid-delta"), - provider: "codex", + provider: ProviderDriverKind.make("codex"), createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-invalid"), @@ -2361,8 +2955,8 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error-after-failure"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-after-failure"), payload: { @@ -2371,7 +2965,7 @@ describe("ProviderRuntimeIngestion", () => { }); const thread = await waitForThread( - harness.engine, + harness.readModel, (entry) => entry.session?.status === "error" && entry.session?.activeTurnId === "turn-after-failure" && diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index c1241241cce..2c07ac91b1e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -4,24 +4,34 @@ import { CommandId, MessageId, type OrchestrationEvent, + type OrchestrationMessage, type OrchestrationProposedPlanId, CheckpointRef, isToolLifecycleItemType, ThreadId, type ThreadTokenUsageSnapshot, TurnId, + type OrchestrationCheckpointSummary, + type OrchestrationProposedPlan, + type OrchestrationThread, type OrchestrationThreadActivity, type ProviderRuntimeEvent, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Stream } from "effect"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +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 Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; -import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, @@ -32,6 +42,12 @@ const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${t const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); +interface AssistantSegmentState { + baseKey: string; + nextSegmentIndex: number; + activeMessageId: MessageId | null; +} + const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; @@ -71,6 +87,82 @@ function sameId(left: string | null | undefined, right: string | null | undefine return left === right; } +function hasAssistantMessageForTurn( + messages: ReadonlyArray, + turnId: TurnId, + options?: { readonly streamingOnly?: boolean }, +): boolean { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (!message) { + continue; + } + if (message.role !== "assistant" || message.turnId !== turnId) { + continue; + } + if (options?.streamingOnly === true && !message.streaming) { + continue; + } + return true; + } + return false; +} + +function findMessageById( + messages: ReadonlyArray, + messageId: MessageId, +): OrchestrationMessage | undefined { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (message?.id === messageId) { + return message; + } + } + return undefined; +} + +function findProposedPlanById( + proposedPlans: ReadonlyArray< + Pick + >, + planId: string, +): + | Pick + | undefined { + for (let index = 0; index < proposedPlans.length; index += 1) { + const proposedPlan = proposedPlans[index]; + if (proposedPlan?.id === planId) { + return proposedPlan; + } + } + return undefined; +} + +function hasCheckpointForTurn( + checkpoints: ReadonlyArray, + turnId: TurnId, +): boolean { + for (let index = 0; index < checkpoints.length; index += 1) { + if (checkpoints[index]?.turnId === turnId) { + return true; + } + } + return false; +} + +function maxCheckpointTurnCount( + checkpoints: ReadonlyArray, +): number { + let maxTurnCount = 0; + for (let index = 0; index < checkpoints.length; index += 1) { + const checkpoint = checkpoints[index]; + if (checkpoint && checkpoint.checkpointTurnCount > maxTurnCount) { + maxTurnCount = checkpoint.checkpointTurnCount; + } + } + return maxTurnCount; +} + function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } @@ -83,6 +175,10 @@ function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string return trimmed; } +function hasRenderableAssistantText(text: string | undefined): boolean { + return (text?.trim().length ?? 0) > 0; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -98,6 +194,15 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId return `plan:${threadId}:event:${event.eventId}`; } +function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string { + return String(event.itemId ?? event.turnId ?? event.eventId); +} + +function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId { + return MessageId.make( + segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`, + ); +} function buildContextWindowActivityPayload( event: ProviderRuntimeEvent, ): ThreadTokenUsageSnapshot | undefined { @@ -465,6 +570,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -500,8 +606,9 @@ function runtimeEventToActivities( return []; } -const make = Effect.fn("make")(function* () { +const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; @@ -518,26 +625,31 @@ const make = Effect.fn("make")(function* () { lookup: () => Effect.succeed(""), }); + const assistantSegmentStateByTurnKey = yield* Cache.make({ + capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, + timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL, + lookup: () => + Effect.die( + new Error("assistant segment state should be read through getOption before initialization"), + ), + }); + const bufferedProposedPlanById = yield* Cache.make({ capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY, timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); - const isGitRepoForThread = Effect.fn("isGitRepoForThread")(function* (threadId: ThreadId) { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === threadId); - if (!thread) { - return false; - } - const workspaceCwd = resolveThreadWorkspaceCwd({ - thread, - projects: readModel.projects, - }); - if (!workspaceCwd) { - return false; - } - return isGitRepository(workspaceCwd); + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + return yield* projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(Effect.map(Option.getOrUndefined)); + }); + + const resolveThreadShell = Effect.fn("resolveThreadShell")(function* (threadId: ThreadId) { + return yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.map(Option.getOrUndefined)); }); const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => @@ -585,10 +697,86 @@ const make = Effect.fn("make")(function* () { const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); + const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); + + const setAssistantSegmentStateForTurn = ( + threadId: ThreadId, + turnId: TurnId, + state: AssistantSegmentState, + ) => Cache.set(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId), state); + + const clearAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); + + const getActiveAssistantMessageIdForTurn = (threadId: ThreadId, turnId: TurnId) => + getAssistantSegmentStateForTurn(threadId, turnId).pipe( + Effect.map((state) => + Option.flatMap(state, (entry) => + entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(), + ), + ), + ); + + const startAssistantSegmentForTurn = (input: { + threadId: ThreadId; + turnId: TurnId; + baseKey: string; + }) => + getAssistantSegmentStateForTurn(input.threadId, input.turnId).pipe( + Effect.flatMap((existingState) => + Effect.gen(function* () { + const nextState = Option.match(existingState, { + onNone: () => ({ + baseKey: input.baseKey, + nextSegmentIndex: 1, + activeMessageId: assistantSegmentMessageId(input.baseKey, 0), + }), + onSome: (state) => { + const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0; + const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex); + return { + baseKey: input.baseKey, + nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1, + activeMessageId: messageId, + } satisfies AssistantSegmentState; + }, + }); + yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState); + return nextState.activeMessageId!; + }), + ), + ); + + const getOrCreateAssistantMessageId = (input: { + threadId: ThreadId; + event: ProviderRuntimeEvent; + turnId?: TurnId; + }) => + Effect.gen(function* () { + if (!input.turnId) { + return assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(input.event), 0); + } + + const activeMessageId = yield* getActiveAssistantMessageIdForTurn( + input.threadId, + input.turnId, + ); + if (Option.isSome(activeMessageId)) { + return activeMessageId.value; + } + + return yield* startAssistantSegmentForTurn({ + threadId: input.threadId, + turnId: input.turnId, + baseKey: assistantSegmentBaseKeyFromEvent(input.event), + }); + }); + const appendBufferedAssistantText = (messageId: MessageId, delta: string) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( - Effect.flatMap( - Effect.fn("appendBufferedAssistantText")(function* (existingText) { + Effect.flatMap((existingText) => + Effect.gen(function* () { const nextText = Option.match(existingText, { onNone: () => delta, onSome: (text) => `${text}${delta}`, @@ -644,48 +832,154 @@ const make = Effect.fn("make")(function* () { const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); - const finalizeAssistantMessage = Effect.fn("finalizeAssistantMessage")(function* (input: { + const flushBufferedAssistantMessage = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; messageId: MessageId; turnId?: TurnId; createdAt: string; commandTag: string; - finalDeltaCommandTag: string; - fallbackText?: string; - }) { - const bufferedText = yield* takeBufferedAssistantText(input.messageId); - const text = - bufferedText.length > 0 - ? bufferedText - : (input.fallbackText?.trim().length ?? 0) > 0 - ? input.fallbackText! - : ""; - - if (text.length > 0) { + }) => + Effect.gen(function* () { + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + if (!hasRenderableAssistantText(bufferedText)) { + return false; + } + yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(input.event, input.finalDeltaCommandTag), + commandId: providerCommandId(input.event, input.commandTag), threadId: input.threadId, messageId: input.messageId, - delta: text, + delta: bufferedText, ...(input.turnId ? { turnId: input.turnId } : {}), createdAt: input.createdAt, }); - } + return true; + }); - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.complete", - commandId: providerCommandId(input.event, input.commandTag), - threadId: input.threadId, - messageId: input.messageId, - ...(input.turnId ? { turnId: input.turnId } : {}), - createdAt: input.createdAt, + const flushBufferedAssistantMessagesForTurn = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + turnId: TurnId; + createdAt: string; + commandTag: string; + }) => + Effect.gen(function* () { + const assistantMessageIds = yield* getAssistantMessageIdsForTurn( + input.threadId, + input.turnId, + ); + const flushedMessageIds = new Set(); + yield* Effect.forEach( + assistantMessageIds, + (messageId) => + flushBufferedAssistantMessage({ + event: input.event, + threadId: input.threadId, + messageId, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: input.commandTag, + }).pipe( + Effect.tap((flushed) => + flushed ? Effect.sync(() => flushedMessageIds.add(messageId)) : Effect.void, + ), + ), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + return flushedMessageIds; + }); + + const finalizeAssistantMessage = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + messageId: MessageId; + turnId?: TurnId; + createdAt: string; + commandTag: string; + finalDeltaCommandTag: string; + fallbackText?: string; + hasProjectedMessage?: boolean; + }) => + Effect.gen(function* () { + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + const text = + bufferedText.length > 0 + ? bufferedText + : (input.fallbackText?.trim().length ?? 0) > 0 + ? input.fallbackText! + : ""; + const hasRenderableText = hasRenderableAssistantText(text); + + if (hasRenderableText) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(input.event, input.finalDeltaCommandTag), + threadId: input.threadId, + messageId: input.messageId, + delta: text, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + } + + if (input.hasProjectedMessage || hasRenderableText) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.complete", + commandId: providerCommandId(input.event, input.commandTag), + threadId: input.threadId, + messageId: input.messageId, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + } + yield* clearAssistantMessageState(input.messageId); }); - yield* clearAssistantMessageState(input.messageId); - }); - const upsertProposedPlan = Effect.fn("upsertProposedPlan")(function* (input: { + const finalizeActiveAssistantSegmentForTurn = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + turnId: TurnId; + createdAt: string; + commandTag: string; + finalDeltaCommandTag: string; + hasProjectedMessage: boolean; + flushedMessageIds?: ReadonlySet; + }) => + Effect.gen(function* () { + const activeMessageId = yield* getActiveAssistantMessageIdForTurn( + input.threadId, + input.turnId, + ); + if (Option.isNone(activeMessageId)) { + return; + } + + yield* finalizeAssistantMessage({ + event: input.event, + threadId: input.threadId, + messageId: activeMessageId.value, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: input.commandTag, + finalDeltaCommandTag: input.finalDeltaCommandTag, + hasProjectedMessage: + input.hasProjectedMessage || + (input.flushedMessageIds?.has(activeMessageId.value) ?? false), + }); + yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId.value); + + const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); + if (Option.isSome(state)) { + yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, { + ...state.value, + activeMessageId: null, + }); + } + }); + + const upsertProposedPlan = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; threadProposedPlans: ReadonlyArray<{ @@ -699,31 +993,32 @@ const make = Effect.fn("make")(function* () { planMarkdown: string | undefined; createdAt: string; updatedAt: string; - }) { - const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); - if (!planMarkdown) { - return; - } + }) => + Effect.gen(function* () { + const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); + if (!planMarkdown) { + return; + } - const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); - yield* orchestrationEngine.dispatch({ - type: "thread.proposed-plan.upsert", - commandId: providerCommandId(input.event, "proposed-plan-upsert"), - threadId: input.threadId, - proposedPlan: { - id: input.planId, - turnId: input.turnId ?? null, - planMarkdown, - implementedAt: existingPlan?.implementedAt ?? null, - implementationThreadId: existingPlan?.implementationThreadId ?? null, - createdAt: existingPlan?.createdAt ?? input.createdAt, - updatedAt: input.updatedAt, - }, - createdAt: input.updatedAt, + const existingPlan = findProposedPlanById(input.threadProposedPlans, input.planId); + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: providerCommandId(input.event, "proposed-plan-upsert"), + threadId: input.threadId, + proposedPlan: { + id: input.planId, + turnId: input.turnId ?? null, + planMarkdown, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + createdAt: existingPlan?.createdAt ?? input.createdAt, + updatedAt: input.updatedAt, + }, + createdAt: input.updatedAt, + }); }); - }); - const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: { + const finalizeBufferedProposedPlan = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; threadProposedPlans: ReadonlyArray<{ @@ -736,65 +1031,75 @@ const make = Effect.fn("make")(function* () { turnId?: TurnId; fallbackMarkdown?: string; updatedAt: string; - }) { - const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); - const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); - const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); - const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; - if (!planMarkdown) { - return; - } + }) => + Effect.gen(function* () { + const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); + const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); + const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); + const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; + if (!planMarkdown) { + return; + } - yield* upsertProposedPlan({ - event: input.event, - threadId: input.threadId, - threadProposedPlans: input.threadProposedPlans, - planId: input.planId, - ...(input.turnId ? { turnId: input.turnId } : {}), - planMarkdown, - createdAt: - bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 - ? bufferedPlan.createdAt - : input.updatedAt, - updatedAt: input.updatedAt, + yield* upsertProposedPlan({ + event: input.event, + threadId: input.threadId, + threadProposedPlans: input.threadProposedPlans, + planId: input.planId, + ...(input.turnId ? { turnId: input.turnId } : {}), + planMarkdown, + createdAt: + bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 + ? bufferedPlan.createdAt + : input.updatedAt, + updatedAt: input.updatedAt, + }); + yield* clearBufferedProposedPlan(input.planId); }); - yield* clearBufferedProposedPlan(input.planId); - }); - const clearTurnStateForSession = Effect.fn("clearTurnStateForSession")(function* ( - threadId: ThreadId, - ) { - const prefix = `${threadId}:`; - const proposedPlanPrefix = `plan:${threadId}:`; - const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); - const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); - yield* Effect.forEach( - turnKeys, - Effect.fn(function* (key) { - if (!key.startsWith(prefix)) { - return; - } + const clearTurnStateForSession = (threadId: ThreadId) => + Effect.gen(function* () { + const prefix = `${threadId}:`; + const proposedPlanPrefix = `plan:${threadId}:`; + const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); + const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); + const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); + yield* Effect.forEach( + turnKeys, + (key) => + Effect.gen(function* () { + if (!key.startsWith(prefix)) { + return; + } - const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); - if (Option.isSome(messageIds)) { - yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { - concurrency: 1, - }).pipe(Effect.asVoid); - } + const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); + if (Option.isSome(messageIds)) { + yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { + concurrency: 1, + }).pipe(Effect.asVoid); + } - yield* Cache.invalidate(turnMessageIdsByTurnKey, key); - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* Effect.forEach( - proposedPlanKeys, - (key) => - key.startsWith(proposedPlanPrefix) - ? Cache.invalidate(bufferedProposedPlanById, key) - : Effect.void, - { concurrency: 1 }, - ).pipe(Effect.asVoid); - }); + yield* Cache.invalidate(turnMessageIdsByTurnKey, key); + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* Effect.forEach( + assistantSegmentKeys, + (key) => + key.startsWith(prefix) + ? Cache.invalidate(assistantSegmentStateByTurnKey, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* Effect.forEach( + proposedPlanKeys, + (key) => + key.startsWith(proposedPlanPrefix) + ? Cache.invalidate(bufferedProposedPlanById, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); + }); const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( "getSourceProposedPlanReferenceForPendingTurnStart", @@ -848,8 +1153,7 @@ const make = Effect.fn("make")(function* () { implementationThreadId: ThreadId, implementedAt: string, ) { - const readModel = yield* orchestrationEngine.getReadModel(); - const sourceThread = readModel.threads.find((entry) => entry.id === sourceThreadId); + const sourceThread = yield* resolveThreadDetail(sourceThreadId); const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { return; @@ -872,353 +1176,452 @@ const make = Effect.fn("make")(function* () { }, ); - const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( - event: ProviderRuntimeEvent, - ) { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); - if (!thread) return; + const processRuntimeEvent = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + const thread = yield* resolveThreadShell(event.threadId); + if (!thread) return; - const now = event.createdAt; - const eventTurnId = toTurnId(event.turnId); - const activeTurnId = thread.session?.activeTurnId ?? null; + let loadedThreadDetail: OrchestrationThread | null | undefined; + const getLoadedThreadDetail = () => + Effect.gen(function* () { + if (loadedThreadDetail !== undefined) { + return loadedThreadDetail; + } + loadedThreadDetail = (yield* resolveThreadDetail(thread.id)) ?? null; + return loadedThreadDetail; + }); - const conflictsWithActiveTurn = - activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); - const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; + const now = event.createdAt; + const eventTurnId = toTurnId(event.turnId); + const activeTurnId = thread.session?.activeTurnId ?? null; - const shouldApplyThreadLifecycle = (() => { - if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { - return true; - } - switch (event.type) { - case "session.exited": - return true; - case "session.started": - case "thread.started": - return true; - case "turn.started": - return !conflictsWithActiveTurn; - case "turn.completed": - if (conflictsWithActiveTurn || missingTurnForActiveTurn) { - return false; - } - // Only the active turn may close the lifecycle state. - if (activeTurnId !== null && eventTurnId !== undefined) { - return sameId(activeTurnId, eventTurnId); - } - // If no active turn is tracked, accept completion scoped to this thread. - return true; - default: + const conflictsWithActiveTurn = + activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); + const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; + + const shouldApplyThreadLifecycle = (() => { + if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { return true; - } - })(); - const acceptedTurnStartedSourcePlan = - event.type === "turn.started" && shouldApplyThreadLifecycle - ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) - : null; - - if ( - event.type === "session.started" || - event.type === "session.state.changed" || - event.type === "session.exited" || - event.type === "thread.started" || - event.type === "turn.started" || - event.type === "turn.completed" - ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" - ? null - : activeTurnId; - const status = (() => { + } switch (event.type) { - case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); - case "turn.started": - return "running"; case "session.exited": - return "stopped"; - case "turn.completed": - return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready"; + return true; case "session.started": case "thread.started": - // Provider thread/session start notifications can arrive during an - // active turn; preserve turn-running state in that case. - return activeTurnId !== null ? "running" : "ready"; + return true; + case "turn.started": + return !conflictsWithActiveTurn; + case "turn.completed": + if (conflictsWithActiveTurn || missingTurnForActiveTurn) { + return false; + } + // Only the active turn may close the lifecycle state. + if (activeTurnId !== null && eventTurnId !== undefined) { + return sameId(activeTurnId, eventTurnId); + } + // If no active turn is tracked, accept completion scoped to this thread. + return true; + default: + return true; } })(); - const lastError = - event.type === "session.state.changed" && event.payload.state === "error" - ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") - : event.type === "turn.completed" && - normalizeRuntimeTurnState(event.payload.state) === "failed" - ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" + const acceptedTurnStartedSourcePlan = + event.type === "turn.started" && shouldApplyThreadLifecycle + ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) + : null; + + if ( + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ) { + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" ? null - : (thread.session?.lastError ?? null); - - if (shouldApplyThreadLifecycle) { - if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { - yield* markSourceProposedPlanImplemented( - acceptedTurnStartedSourcePlan.sourceThreadId, - acceptedTurnStartedSourcePlan.sourcePlanId, - thread.id, - now, - ).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider runtime ingestion failed to mark source proposed plan", { - eventId: event.eventId, - eventType: event.type, - cause: Cause.pretty(cause), - }), - ), - ); - } + : activeTurnId; + const status = (() => { + switch (event.type) { + case "session.state.changed": + return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "turn.started": + return "running"; + case "session.exited": + return "stopped"; + case "turn.completed": + return normalizeRuntimeTurnState(event.payload.state) === "failed" + ? "error" + : "ready"; + case "session.started": + case "thread.started": + // Provider thread/session start notifications can arrive during an + // active turn; preserve turn-running state in that case. + return activeTurnId !== null ? "running" : "ready"; + } + })(); + const lastError = + event.type === "session.state.changed" && event.payload.state === "error" + ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") + : event.type === "turn.completed" && + normalizeRuntimeTurnState(event.payload.state) === "failed" + ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") + : status === "ready" + ? null + : (thread.session?.lastError ?? null); + + if (shouldApplyThreadLifecycle) { + if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { + yield* markSourceProposedPlanImplemented( + acceptedTurnStartedSourcePlan.sourceThreadId, + acceptedTurnStartedSourcePlan.sourcePlanId, + thread.id, + now, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime ingestion failed to mark source proposed plan", + { + eventId: event.eventId, + eventType: event.type, + cause: Cause.pretty(cause), + }, + ), + ), + ); + } - yield* orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: providerCommandId(event, "thread-session-set"), - threadId: thread.id, - session: { + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: providerCommandId(event, "thread-session-set"), threadId: thread.id, - status, - providerName: event.provider, - runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: nextActiveTurnId, - lastError, - updatedAt: now, - }, - createdAt: now, - }); + session: { + threadId: thread.id, + status, + providerName: event.provider, + ...(event.providerInstanceId !== undefined + ? { providerInstanceId: event.providerInstanceId } + : {}), + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: nextActiveTurnId, + lastError, + updatedAt: now, + }, + createdAt: now, + }); + } } - } - const assistantDelta = - event.type === "content.delta" && event.payload.streamKind === "assistant_text" - ? event.payload.delta - : undefined; - const proposedPlanDelta = - event.type === "turn.proposed.delta" ? event.payload.delta : undefined; + const assistantDelta = + event.type === "content.delta" && event.payload.streamKind === "assistant_text" + ? event.payload.delta + : undefined; + const proposedPlanDelta = + event.type === "turn.proposed.delta" ? event.payload.delta : undefined; - if (assistantDelta && assistantDelta.length > 0) { - const assistantMessageId = MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); - const turnId = toTurnId(event.turnId); - if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); - } + if (assistantDelta && assistantDelta.length > 0) { + const turnId = toTurnId(event.turnId); + const assistantMessageId = yield* getOrCreateAssistantMessageId({ + threadId: thread.id, + event, + ...(turnId ? { turnId } : {}), + }); + if (turnId) { + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); + } - const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), - ); - if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); - if (spillChunk.length > 0) { + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); + if (assistantDeliveryMode === "buffered") { + const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + if (spillChunk.length > 0) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + threadId: thread.id, + messageId: assistantMessageId, + delta: spillChunk, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + } + } else { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + commandId: providerCommandId(event, "assistant-delta"), threadId: thread.id, messageId: assistantMessageId, - delta: spillChunk, + delta: assistantDelta, ...(turnId ? { turnId } : {}), createdAt: now, }); } - } else { - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta"), + } + + const pauseForUserTurnId = + event.type === "request.opened" || event.type === "user-input.requested" + ? toTurnId(event.turnId) + : undefined; + if (pauseForUserTurnId) { + const detailedThread = yield* getLoadedThreadDetail(); + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); + const flushedMessageIds = + assistantDeliveryMode === "buffered" + ? yield* flushBufferedAssistantMessagesForTurn({ + event, + threadId: thread.id, + turnId: pauseForUserTurnId, + createdAt: now, + commandTag: + event.type === "request.opened" + ? "assistant-delta-flush-on-request-opened" + : "assistant-delta-flush-on-user-input-requested", + }) + : new Set(); + yield* finalizeActiveAssistantSegmentForTurn({ + event, threadId: thread.id, - messageId: assistantMessageId, - delta: assistantDelta, - ...(turnId ? { turnId } : {}), + turnId: pauseForUserTurnId, createdAt: now, + commandTag: + event.type === "request.opened" + ? "assistant-complete-on-request-opened" + : "assistant-complete-on-user-input-requested", + finalDeltaCommandTag: + event.type === "request.opened" + ? "assistant-delta-finalize-on-request-opened" + : "assistant-delta-finalize-on-user-input-requested", + hasProjectedMessage: + detailedThread !== null && + hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, { + streamingOnly: true, + }), + flushedMessageIds, }); } - } - if (proposedPlanDelta && proposedPlanDelta.length > 0) { - const planId = proposedPlanIdFromEvent(event, thread.id); - yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); - } + if (proposedPlanDelta && proposedPlanDelta.length > 0) { + const planId = proposedPlanIdFromEvent(event, thread.id); + yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); + } - const assistantCompletion = - event.type === "item.completed" && event.payload.itemType === "assistant_message" - ? { - messageId: MessageId.make(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), - fallbackText: event.payload.detail, - } - : undefined; - const proposedPlanCompletion = - event.type === "turn.proposed.completed" - ? { - planId: proposedPlanIdFromEvent(event, thread.id), - turnId: toTurnId(event.turnId), - planMarkdown: event.payload.planMarkdown, + const assistantCompletion = + event.type === "item.completed" && event.payload.itemType === "assistant_message" + ? { + messageId: MessageId.make( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ), + fallbackText: event.payload.detail, + } + : undefined; + const proposedPlanCompletion = + event.type === "turn.proposed.completed" + ? { + planId: proposedPlanIdFromEvent(event, thread.id), + turnId: toTurnId(event.turnId), + planMarkdown: event.payload.planMarkdown, + } + : undefined; + + if (assistantCompletion) { + const detailedThread = yield* getLoadedThreadDetail(); + const messages = detailedThread?.messages ?? []; + const turnId = toTurnId(event.turnId); + const activeAssistantMessageId = turnId + ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) + : Option.none(); + const hasAssistantMessagesForTurn = + turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false; + const assistantMessageId = Option.getOrElse( + activeAssistantMessageId, + () => assistantCompletion.messageId, + ); + const existingAssistantMessage = findMessageById(messages, assistantMessageId); + const shouldApplyFallbackCompletionText = + !existingAssistantMessage || existingAssistantMessage.text.length === 0; + + const shouldSkipRedundantCompletion = + Option.isNone(activeAssistantMessageId) && + turnId !== undefined && + hasAssistantMessagesForTurn && + (assistantCompletion.fallbackText?.trim().length ?? 0) === 0; + + if (!shouldSkipRedundantCompletion) { + if (turnId && Option.isNone(activeAssistantMessageId)) { + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - : undefined; - if (assistantCompletion) { - const assistantMessageId = assistantCompletion.messageId; - const turnId = toTurnId(event.turnId); - const existingAssistantMessage = thread.messages.find( - (entry) => entry.id === assistantMessageId, - ); - const shouldApplyFallbackCompletionText = - !existingAssistantMessage || existingAssistantMessage.text.length === 0; - if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); - } + yield* finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + ...(turnId ? { turnId } : {}), + createdAt: now, + commandTag: "assistant-complete", + finalDeltaCommandTag: "assistant-delta-finalize", + hasProjectedMessage: existingAssistantMessage !== undefined, + ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText + ? { fallbackText: assistantCompletion.fallbackText } + : {}), + }); - yield* finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - ...(turnId ? { turnId } : {}), - createdAt: now, - commandTag: "assistant-complete", - finalDeltaCommandTag: "assistant-delta-finalize", - ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText - ? { fallbackText: assistantCompletion.fallbackText } - : {}), - }); + if (turnId) { + yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + } + } - if (turnId) { - yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + if (turnId) { + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); + } } - } - - if (proposedPlanCompletion) { - yield* finalizeBufferedProposedPlan({ - event, - threadId: thread.id, - threadProposedPlans: thread.proposedPlans, - planId: proposedPlanCompletion.planId, - ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), - fallbackMarkdown: proposedPlanCompletion.planMarkdown, - updatedAt: now, - }); - } - - if (event.type === "turn.completed") { - const turnId = toTurnId(event.turnId); - if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); - yield* Effect.forEach( - assistantMessageIds, - (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + if (proposedPlanCompletion) { + const detailedThread = yield* getLoadedThreadDetail(); yield* finalizeBufferedProposedPlan({ event, threadId: thread.id, - threadProposedPlans: thread.proposedPlans, - planId: proposedPlanIdForTurn(thread.id, turnId), - turnId, + threadProposedPlans: detailedThread?.proposedPlans ?? [], + planId: proposedPlanCompletion.planId, + ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), + fallbackMarkdown: proposedPlanCompletion.planMarkdown, updatedAt: now, }); } - } - if (event.type === "session.exited") { - yield* clearTurnStateForSession(thread.id); - } - - if (event.type === "runtime.error") { - const runtimeErrorMessage = event.payload.message; - - const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD - ? true - : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); + if (event.type === "turn.completed") { + const detailedThread = yield* getLoadedThreadDetail(); + const messages = detailedThread?.messages ?? []; + const proposedPlans = detailedThread?.proposedPlans ?? []; + const turnId = toTurnId(event.turnId); + if (turnId) { + const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + yield* Effect.forEach( + assistantMessageIds, + (assistantMessageId) => + finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + turnId, + createdAt: now, + commandTag: "assistant-complete-finalize", + finalDeltaCommandTag: "assistant-delta-finalize-fallback", + hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); - if (shouldApplyRuntimeError) { - yield* orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: providerCommandId(event, "runtime-error-session-set"), - threadId: thread.id, - session: { + yield* finalizeBufferedProposedPlan({ + event, threadId: thread.id, - status: "error", - providerName: event.provider, - runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: eventTurnId ?? null, - lastError: runtimeErrorMessage, + threadProposedPlans: proposedPlans, + planId: proposedPlanIdForTurn(thread.id, turnId), + turnId, updatedAt: now, - }, - createdAt: now, - }); + }); + } } - } - if (event.type === "thread.metadata.updated" && event.payload.name) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); - } + if (event.type === "session.exited") { + yield* clearTurnStateForSession(thread.id); + } - if (event.type === "turn.diff.updated") { - const turnId = toTurnId(event.turnId); - if (turnId && (yield* isGitRepoForThread(thread.id))) { - // Skip if a checkpoint already exists for this turn. A real - // (non-placeholder) capture from CheckpointReactor should not - // be clobbered, and dispatching a duplicate placeholder for the - // same turnId would produce an unstable checkpointTurnCount. - if (thread.checkpoints.some((c) => c.turnId === turnId)) { - // Already tracked; no-op. - } else { - const assistantMessageId = MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); - const maxTurnCount = thread.checkpoints.reduce( - (max, c) => Math.max(max, c.checkpointTurnCount), - 0, - ); + if (event.type === "runtime.error") { + const runtimeErrorMessage = event.payload.message; + + const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD + ? true + : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); + + if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ - type: "thread.turn.diff.complete", - commandId: providerCommandId(event, "thread-turn-diff-complete"), + type: "thread.session.set", + commandId: providerCommandId(event, "runtime-error-session-set"), threadId: thread.id, - turnId, - completedAt: now, - checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`), - status: "missing", - files: [], - assistantMessageId, - checkpointTurnCount: maxTurnCount + 1, + session: { + threadId: thread.id, + status: "error", + providerName: event.provider, + ...(event.providerInstanceId !== undefined + ? { providerInstanceId: event.providerInstanceId } + : {}), + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: eventTurnId ?? null, + lastError: runtimeErrorMessage, + updatedAt: now, + }, createdAt: now, }); } } - } - const activities = runtimeEventToActivities(event); - yield* Effect.forEach(activities, (activity) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: providerCommandId(event, "thread-activity-append"), - threadId: thread.id, - activity, - createdAt: activity.createdAt, - }), - ).pipe(Effect.asVoid); - }); + if (event.type === "thread.metadata.updated" && event.payload.name) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } + + if (event.type === "turn.diff.updated") { + const turnId = toTurnId(event.turnId); + const checkpointContext = turnId + ? yield* projectionSnapshotQuery + .getThreadCheckpointContext(thread.id) + .pipe(Effect.map(Option.getOrUndefined)) + : undefined; + const workspaceCwd = + checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined; + if (turnId && checkpointContext && workspaceCwd && isGitRepository(workspaceCwd)) { + // Skip if a checkpoint already exists for this turn. A real + // (non-placeholder) capture from CheckpointReactor should not + // be clobbered, and dispatching a duplicate placeholder for the + // same turnId would produce an unstable checkpointTurnCount. + if (hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) { + // Already tracked; no-op. + } else { + const assistantMessageId = MessageId.make( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.turn.diff.complete", + commandId: providerCommandId(event, "thread-turn-diff-complete"), + threadId: thread.id, + turnId, + completedAt: now, + checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`), + status: "missing", + files: [], + assistantMessageId, + checkpointTurnCount: maxCheckpointTurnCount(checkpointContext.checkpoints) + 1, + createdAt: now, + }); + } + } + } + + const activities = runtimeEventToActivities(event); + yield* Effect.forEach(activities, (activity) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: providerCommandId(event, "thread-activity-append"), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }), + ).pipe(Effect.asVoid); + }); const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; @@ -1242,21 +1645,22 @@ const make = Effect.fn("make")(function* () { const worker = yield* makeDrainableWorker(processInputSafely); - const start: ProviderRuntimeIngestionShape["start"] = Effect.fn("start")(function* () { - yield* Effect.forkScoped( - Stream.runForEach(providerService.streamEvents, (event) => - worker.enqueue({ source: "runtime", event }), - ), - ); - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.turn-start-requested") { - return Effect.void; - } - return worker.enqueue({ source: "domain", event }); - }), - ); - }); + const start: ProviderRuntimeIngestionShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + Stream.runForEach(providerService.streamEvents, (event) => + worker.enqueue({ source: "runtime", event }), + ), + ); + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.turn-start-requested") { + return Effect.void; + } + return worker.enqueue({ source: "domain", event }); + }), + ); + }); return { start, @@ -1266,5 +1670,5 @@ const make = Effect.fn("make")(function* () { export const ProviderRuntimeIngestionLive = Layer.effect( ProviderRuntimeIngestionService, - make(), + make, ).pipe(Layer.provide(ProjectionTurnRepositoryLive)); diff --git a/apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts b/apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts index 5c3148c9434..586281dc9c6 100644 --- a/apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts +++ b/apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts @@ -8,7 +8,10 @@ * * @module RuntimeReceiptBus */ -import { Effect, Layer, PubSub, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; import { RuntimeReceiptBus, diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts new file mode 100644 index 00000000000..701835da16c --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -0,0 +1,38 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { describe, expect, it } from "vitest"; + +import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; + +describe("logCleanupCauseUnlessInterrupted", () => { + const threadId = ThreadId.make("thread-deletion-reactor-test"); + + it("swallows ordinary cleanup failures", async () => { + const exit = await Effect.runPromiseExit( + logCleanupCauseUnlessInterrupted({ + effect: Effect.fail("cleanup failed"), + message: "thread deletion cleanup skipped provider session stop", + threadId, + }), + ); + + expect(Exit.isSuccess(exit)).toBe(true); + }); + + it("preserves interrupt causes", async () => { + const exit = await Effect.runPromiseExit( + logCleanupCauseUnlessInterrupted({ + effect: Effect.interrupt, + message: "thread deletion cleanup skipped provider session stop", + threadId, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts new file mode 100644 index 00000000000..4bbf5ca2149 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -0,0 +1,99 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + ThreadDeletionReactor, + type ThreadDeletionReactorShape, +} from "../Services/ThreadDeletionReactor.ts"; + +type ThreadDeletedEvent = Extract; + +export const logCleanupCauseUnlessInterrupted = ({ + effect, + message, + threadId, +}: { + readonly effect: Effect.Effect; + readonly message: string; + readonly threadId: ThreadDeletedEvent["payload"]["threadId"]; +}): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logDebug(message, { + threadId, + cause: Cause.pretty(cause), + }); + }), + ); + +const make = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const providerService = yield* ProviderService; + const terminalManager = yield* TerminalManager; + + const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: providerService.stopSession({ threadId }), + message: "thread deletion cleanup skipped provider session stop", + threadId, + }); + + const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: terminalManager.close({ threadId, deleteHistory: true }), + message: "thread deletion cleanup skipped terminal close", + threadId, + }); + + const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( + event: ThreadDeletedEvent, + ) { + const { threadId } = event.payload; + yield* stopProviderSession(threadId); + yield* closeThreadTerminals(threadId); + }); + + const processThreadDeletedSafely = (event: ThreadDeletedEvent) => + processThreadDeleted(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("thread deletion reactor failed to process event", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }); + }), + ); + + const worker = yield* makeDrainableWorker(processThreadDeletedSafely); + + const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.deleted") { + return Effect.void; + } + return worker.enqueue(event); + }), + ); + }); + + return { + start, + drain: worker.drain, + } satisfies ThreadDeletionReactorShape; +}); + +export const ThreadDeletionReactorLive = Layer.effect(ThreadDeletionReactor, make); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 69863b7f0ad..95d29e3d6d2 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -1,4 +1,6 @@ -import { Effect, FileSystem, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { type ClientOrchestrationCommand, type OrchestrationCommand, @@ -6,10 +8,10 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore"; -import { ServerConfig } from "../config"; -import { parseBase64DataUrl } from "../imageMime"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths"; +import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { parseBase64DataUrl } from "../imageMime.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { @@ -28,10 +30,31 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ), ); + const normalizeProjectWorkspaceRootForCreate = ( + workspaceRoot: string, + createIfMissing: boolean | undefined, + ) => + workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { + createIfMissing: createIfMissing === true, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: cause.message, + }), + ), + ); + if (command.type === "project.create") { return { ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate( + command.workspaceRoot, + command.createWorkspaceRootIfMissing, + ), + createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true, } satisfies OrchestrationCommand; } diff --git a/apps/server/src/orchestration/Services/CheckpointReactor.ts b/apps/server/src/orchestration/Services/CheckpointReactor.ts index 9e9c83beb47..bd3ee3e88f9 100644 --- a/apps/server/src/orchestration/Services/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Services/CheckpointReactor.ts @@ -6,8 +6,9 @@ * * @module CheckpointReactor */ -import { Context } from "effect"; -import type { Effect, Scope } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; /** * CheckpointReactorShape - Service API for checkpoint reactor lifecycle. diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index 376b87d30a0..acb2b7b042d 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -10,13 +10,10 @@ * * @module OrchestrationEngineService */ -import type { - OrchestrationCommand, - OrchestrationEvent, - OrchestrationReadModel, -} from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect, Stream } from "effect"; +import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; import type { OrchestrationDispatchError } from "../Errors.ts"; import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; @@ -25,13 +22,6 @@ import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; * OrchestrationEngineShape - Service API for orchestration command and event flow. */ export interface OrchestrationEngineShape { - /** - * Read the current in-memory orchestration read model. - * - * @returns Effect containing the latest read model. - */ - readonly getReadModel: () => Effect.Effect; - /** * Replay persisted orchestration events from an exclusive sequence cursor. * @@ -70,7 +60,7 @@ export interface OrchestrationEngineShape { * ```ts * const program = Effect.gen(function* () { * const engine = yield* OrchestrationEngineService - * return yield* engine.getReadModel() + * return yield* engine.dispatch(command) * }) * ``` */ diff --git a/apps/server/src/orchestration/Services/OrchestrationReactor.ts b/apps/server/src/orchestration/Services/OrchestrationReactor.ts index a3edeaac61d..eb2d95954bb 100644 --- a/apps/server/src/orchestration/Services/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Services/OrchestrationReactor.ts @@ -6,8 +6,9 @@ * * @module OrchestrationReactor */ -import { Context } from "effect"; -import type { Effect, Scope } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; /** * OrchestrationReactorShape - Service API for orchestration reactor lifecycle. diff --git a/apps/server/src/orchestration/Services/ProjectionPipeline.ts b/apps/server/src/orchestration/Services/ProjectionPipeline.ts index 349f3430ad6..bb4736ca57a 100644 --- a/apps/server/src/orchestration/Services/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Services/ProjectionPipeline.ts @@ -7,8 +7,8 @@ * @module OrchestrationProjectionPipeline */ import type { OrchestrationEvent } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 36bbf5028b4..7d85f0240f7 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,15 +7,20 @@ * @module ProjectionSnapshotQuery */ import type { + CheckpointRef, OrchestrationCheckpointSummary, OrchestrationProject, + OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThread, + OrchestrationThreadShell, ProjectId, ThreadId, } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Option } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Option from "effect/Option"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; @@ -24,6 +29,10 @@ export interface ProjectionSnapshotCounts { readonly threadCount: number; } +export interface ProjectionSnapshotSequence { + readonly snapshotSequence: number; +} + export interface ProjectionThreadCheckpointContext { readonly threadId: ThreadId; readonly projectId: ProjectId; @@ -32,10 +41,28 @@ export interface ProjectionThreadCheckpointContext { readonly checkpoints: ReadonlyArray; } +export interface ProjectionFullThreadDiffContext { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly worktreePath: string | null; + readonly latestCheckpointTurnCount: number; + readonly toCheckpointRef: CheckpointRef | null; +} + /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ export interface ProjectionSnapshotQueryShape { + /** + * Read the lightweight command snapshot used to bootstrap the in-memory + * orchestration engine without hydrating message/activity/checkpoint bodies. + */ + readonly getCommandReadModel: () => Effect.Effect< + OrchestrationReadModel, + ProjectionRepositoryError + >; + /** * Read the latest orchestration projection snapshot. * @@ -44,6 +71,37 @@ export interface ProjectionSnapshotQueryShape { */ readonly getSnapshot: () => Effect.Effect; + /** + * Read the latest orchestration shell snapshot. + * + * Returns only projects and thread shell summaries so clients can bootstrap + * lightweight navigation state without hydrating every thread body. + */ + readonly getShellSnapshot: () => Effect.Effect< + OrchestrationShellSnapshot, + ProjectionRepositoryError + >; + + /** + * Read archived thread shell summaries for the archive page. + * + * This query is separate from the main shell snapshot so archived threads + * are never bootstrapped into normal navigation state. + */ + readonly getArchivedShellSnapshot: () => Effect.Effect< + OrchestrationShellSnapshot, + ProjectionRepositoryError + >; + + /** + * Read the latest projection snapshot sequence without hydrating read-model + * entities. + */ + readonly getSnapshotSequence: () => Effect.Effect< + ProjectionSnapshotSequence, + ProjectionRepositoryError + >; + /** * Read aggregate projection counts without hydrating the full read model. */ @@ -56,6 +114,13 @@ export interface ProjectionSnapshotQueryShape { workspaceRoot: string, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read a single active project shell row by id. + */ + readonly getProjectShellById: ( + projectId: ProjectId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the earliest active thread for a project. */ @@ -69,6 +134,29 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadCheckpointContext: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read only the narrow context needed to compute a full-thread diff from + * checkpoint 0 to a specific turn count. + */ + readonly getFullThreadDiffContext: ( + threadId: ThreadId, + toTurnCount: number, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread shell row by id. + */ + readonly getThreadShellById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread detail snapshot by id. + */ + readonly getThreadDetailById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts index c8b8580682a..65aa9949fe1 100644 --- a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts @@ -6,8 +6,9 @@ * * @module ProviderCommandReactor */ -import { Context } from "effect"; -import type { Effect, Scope } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; /** * ProviderCommandReactorShape - Service API for provider command reactors. diff --git a/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts index 831ce3a6f0c..b6fa2711b94 100644 --- a/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts @@ -6,8 +6,9 @@ * * @module ProviderRuntimeIngestionService */ -import { Context } from "effect"; -import type { Effect, Scope } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; /** * ProviderRuntimeIngestionShape - Service API for runtime ingestion lifecycle. diff --git a/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts b/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts index 53038006dea..0b880ee6999 100644 --- a/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts +++ b/apps/server/src/orchestration/Services/RuntimeReceiptBus.ts @@ -15,8 +15,10 @@ * @module RuntimeReceiptBus */ import { CheckpointRef, IsoDateTime, NonNegativeInt, ThreadId, TurnId } from "@t3tools/contracts"; -import { Schema, Context } from "effect"; -import type { Effect, Stream } from "effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; export const CheckpointBaselineCapturedReceipt = Schema.Struct({ type: Schema.Literal("checkpoint.baseline.captured"), diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts new file mode 100644 index 00000000000..7c6718965a6 --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts @@ -0,0 +1,38 @@ +/** + * ThreadDeletionReactor - Thread deletion cleanup reactor service interface. + * + * Owns background workers that react to thread deletion domain events and + * perform best-effort runtime cleanup for provider sessions and terminals. + * + * @module ThreadDeletionReactor + */ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +/** + * ThreadDeletionReactorShape - Service API for thread deletion cleanup. + */ +export interface ThreadDeletionReactorShape { + /** + * Start reacting to thread.deleted orchestration domain events. + * + * The returned effect must be run in a scope so all worker fibers can be + * finalized on shutdown. + */ + readonly start: () => Effect.Effect; + + /** + * Resolves when the internal processing queue is empty and idle. + * Intended for test use to replace timing-sensitive sleeps. + */ + readonly drain: Effect.Effect; +} + +/** + * ThreadDeletionReactor - Service tag for thread deletion cleanup workers. + */ +export class ThreadDeletionReactor extends Context.Service< + ThreadDeletionReactor, + ThreadDeletionReactorShape +>()("t3/orchestration/Services/ThreadDeletionReactor") {} diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index a678bcea166..d52f0535fbb 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -7,8 +7,9 @@ import { ThreadId, type OrchestrationCommand, type OrchestrationReadModel, + ProviderInstanceId, } from "@t3tools/contracts"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import { findThreadById, @@ -18,7 +19,7 @@ import { requireThreadAbsent, } from "./commandInvariants.ts"; -const now = new Date().toISOString(); +const now = "2026-01-01T00:00:00.000Z"; const readModel: OrchestrationReadModel = { snapshotSequence: 2, @@ -29,7 +30,7 @@ const readModel: OrchestrationReadModel = { title: "Project A", workspaceRoot: "/tmp/project-a", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [], @@ -42,7 +43,7 @@ const readModel: OrchestrationReadModel = { title: "Project B", workspaceRoot: "/tmp/project-b", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, scripts: [], @@ -57,7 +58,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.make("project-a"), title: "Thread A", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -80,7 +81,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.make("project-b"), title: "Thread B", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -157,7 +158,7 @@ describe("commandInvariants", () => { projectId: ProjectId.make("project-a"), title: "new", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -181,7 +182,7 @@ describe("commandInvariants", () => { projectId: ProjectId.make("project-a"), title: "dup", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 009fdb190e5..f5ab794bce7 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -6,7 +6,7 @@ import type { ProjectId, ThreadId, } from "@t3tools/contracts"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts new file mode 100644 index 00000000000..dcdaaecc1fb --- /dev/null +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -0,0 +1,227 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationReadModel, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asCommandId = (value: string): CommandId => CommandId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +async function seedReadModel(): Promise { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-delete"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-create"), + causationEventId: null, + correlationId: asCommandId("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-delete"), + title: "Project Delete", + workspaceRoot: "/tmp/project-delete", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + const withFirstThread = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + projectId: asProjectId("project-delete"), + title: "Thread Delete 1", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + return Effect.runPromise( + projectEvent(withFirstThread, { + sequence: 3, + eventId: asEventId("evt-thread-create-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-2"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-2"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-2"), + projectId: asProjectId("project-delete"), + title: "Thread Delete 2", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); +} + +type PlannedEvent = Omit; + +function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray) { + const events = Array.isArray(event) ? event : [event]; + return events.map((entry) => { + switch (entry.type) { + case "thread.deleted": + return { + type: entry.type, + aggregateKind: entry.aggregateKind, + aggregateId: entry.aggregateId, + commandId: entry.commandId, + correlationId: entry.correlationId, + payload: { + threadId: entry.payload.threadId, + }, + }; + case "project.deleted": + return { + type: entry.type, + aggregateKind: entry.aggregateKind, + aggregateId: entry.aggregateId, + commandId: entry.commandId, + correlationId: entry.correlationId, + payload: { + projectId: entry.payload.projectId, + }, + }; + default: + return entry; + } + }); +} + +describe("decider deletion flows", () => { + it("rejects deleting a non-empty project without force", async () => { + const readModel = await seedReadModel(); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-no-force"), + projectId: asProjectId("project-delete"), + }, + readModel, + }), + ), + ).rejects.toThrow("cannot be deleted without force=true"); + }); + + it("reuses thread.delete semantics when force-deleting a non-empty project", async () => { + const readModel = await seedReadModel(); + const projectDeleteCommand: Extract = { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-force"), + projectId: asProjectId("project-delete"), + force: true, + }; + + const forcedResult = await Effect.runPromise( + decideOrchestrationCommand({ + command: projectDeleteCommand, + readModel, + }), + ); + const forcedEvents = Array.isArray(forcedResult) ? forcedResult : [forcedResult]; + + expect(forcedEvents.map((event) => event.type)).toEqual([ + "thread.deleted", + "thread.deleted", + "project.deleted", + ]); + + let sequentialReadModel = readModel; + let nextSequence = readModel.snapshotSequence; + const sequentialEvents: PlannedEvent[] = []; + for (const nextCommand of [ + { + type: "thread.delete", + commandId: projectDeleteCommand.commandId, + threadId: asThreadId("thread-delete-1"), + }, + { + type: "thread.delete", + commandId: projectDeleteCommand.commandId, + threadId: asThreadId("thread-delete-2"), + }, + { + type: "project.delete", + commandId: projectDeleteCommand.commandId, + projectId: asProjectId("project-delete"), + }, + ] satisfies ReadonlyArray) { + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: nextCommand, + readModel: sequentialReadModel, + }), + ); + const nextEvents = Array.isArray(decided) ? decided : [decided]; + sequentialEvents.push(...nextEvents); + for (const nextEvent of nextEvents) { + nextSequence += 1; + sequentialReadModel = await Effect.runPromise( + projectEvent(sequentialReadModel, { + ...nextEvent, + sequence: nextSequence, + }), + ); + } + } + + expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents)); + }); +}); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index a85e21c53f3..c5b7086eb12 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -5,9 +5,11 @@ import { MessageId, ProjectId, ThreadId, + ProviderInstanceId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import { decideOrchestrationCommand } from "./decider.ts"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -18,7 +20,7 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); describe("decider project scripts", () => { it("emits empty scripts on project.create", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const readModel = createEmptyReadModel(now); const result = await Effect.runPromise( @@ -41,7 +43,7 @@ describe("decider project scripts", () => { }); it("propagates scripts in project.meta.update payload", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const initial = createEmptyReadModel(now); const readModel = await Effect.runPromise( projectEvent(initial, { @@ -95,7 +97,7 @@ describe("decider project scripts", () => { }); it("emits user message and turn-start-requested events for thread.turn.start", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const initial = createEmptyReadModel(now); const withProject = await Effect.runPromise( projectEvent(initial, { @@ -137,7 +139,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -162,14 +164,10 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -191,20 +189,16 @@ describe("decider project scripts", () => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.make("thread-1"), messageId: asMessageId("message-user-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), runtimeMode: "approval-required", }); }); it("emits thread.runtime-mode-set from thread.runtime-mode.set", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const initial = createEmptyReadModel(now); const withProject = await Effect.runPromise( projectEvent(initial, { @@ -246,7 +240,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -286,7 +280,7 @@ describe("decider project scripts", () => { }); it("emits thread.interaction-mode-set from thread.interaction-mode.set", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const initial = createEmptyReadModel(now); const withProject = await Effect.runPromise( projectEvent(initial, { @@ -328,7 +322,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb280d..1004c945dbf 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,10 +3,12 @@ import type { OrchestrationEvent, OrchestrationReadModel, } from "@t3tools/contracts"; -import { Effect } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { + listThreadsByProjectId, requireProject, requireProjectAbsent, requireThread, @@ -14,18 +16,9 @@ import { requireThreadAbsent, requireThreadNotArchived, } from "./commandInvariants.ts"; +import { projectEvent } from "./projector.ts"; -const nowIso = () => new Date().toISOString(); -const defaultMetadata: Omit = { - eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], - aggregateKind: "thread", - aggregateId: "" as OrchestrationEvent["aggregateId"], - occurredAt: nowIso(), - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, -}; +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function withEventBase( input: Pick & { @@ -36,27 +29,60 @@ function withEventBase( }, ): Omit { return { - ...defaultMetadata, eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], aggregateKind: input.aggregateKind, aggregateId: input.aggregateId, occurredAt: input.occurredAt, commandId: input.commandId, + causationEventId: null, correlationId: input.commandId, metadata: input.metadata ?? {}, }; } +type PlannedOrchestrationEvent = Omit; + +type DecideOrchestrationCommandResult = + | PlannedOrchestrationEvent + | ReadonlyArray; + +const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ + commands, + readModel, +}: { + readonly commands: ReadonlyArray; + readonly readModel: OrchestrationReadModel; +}): Effect.fn.Return, OrchestrationCommandInvariantError> { + let nextReadModel = readModel; + let nextSequence = readModel.snapshotSequence; + const plannedEvents: PlannedOrchestrationEvent[] = []; + + for (const nextCommand of commands) { + const decided = yield* decideOrchestrationCommand({ + command: nextCommand, + readModel: nextReadModel, + }); + const nextEvents = Array.isArray(decided) ? decided : [decided]; + for (const nextEvent of nextEvents) { + plannedEvents.push(nextEvent); + nextSequence += 1; + nextReadModel = yield* projectEvent(nextReadModel, { + ...nextEvent, + sequence: nextSequence, + }).pipe(Effect.orDie); + } + } + + return plannedEvents; +}); + export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ command, readModel, }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; -}): Effect.fn.Return< - Omit | ReadonlyArray>, - OrchestrationCommandInvariantError -> { +}): Effect.fn.Return { switch (command.type) { case "project.create": { yield* requireProjectAbsent({ @@ -91,7 +117,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); - const occurredAt = nowIso(); + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "project", @@ -119,7 +145,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); - const occurredAt = nowIso(); + const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter( + (thread) => thread.deletedAt === null, + ); + if (activeThreads.length > 0 && command.force !== true) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`, + }); + } + if (activeThreads.length > 0) { + return yield* decideCommandSequence({ + readModel, + commands: [ + ...activeThreads.map( + (thread): Extract => ({ + type: "thread.delete", + commandId: command.commandId, + threadId: thread.id, + }), + ), + { + type: "project.delete", + commandId: command.commandId, + projectId: command.projectId, + }, + ], + }); + } + + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "project", @@ -127,7 +182,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" occurredAt, commandId: command.commandId, }), - type: "project.deleted", + type: "project.deleted" as const, payload: { projectId: command.projectId, deletedAt: occurredAt, @@ -175,7 +230,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const occurredAt = nowIso(); + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "thread", @@ -197,7 +252,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const occurredAt = nowIso(); + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "thread", @@ -220,7 +275,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const occurredAt = nowIso(); + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "thread", @@ -242,7 +297,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const occurredAt = nowIso(); + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "thread", @@ -270,7 +325,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const occurredAt = nowIso(); + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "thread", @@ -293,7 +348,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const occurredAt = nowIso(); + const occurredAt = yield* nowIso; return { ...withEventBase({ aggregateKind: "thread", diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 959d841f673..f24f808c6f8 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -4,7 +4,7 @@ import { OrchestrationGetSnapshotError, type OrchestrationReadModel, } from "@t3tools/contracts"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { ServerAuth } from "../auth/Services/ServerAuth.ts"; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index a61153bb529..01dcb9abeac 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -2,10 +2,11 @@ import { CommandId, EventId, ProjectId, + ProviderDriverKind, ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import { describe, expect, it } from "vitest"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -39,7 +40,7 @@ function makeEvent(input: { describe("orchestration projector", () => { it("applies thread.created events", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); const next = await Effect.runPromise( @@ -57,7 +58,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -77,7 +78,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + instanceId: "codex", model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -99,7 +100,7 @@ describe("orchestration projector", () => { }); it("fails when event payload cannot be decoded by runtime schema", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); await expect( @@ -118,7 +119,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, branch: null, @@ -133,8 +134,8 @@ describe("orchestration projector", () => { }); it("applies thread.archived and thread.unarchived events", async () => { - const now = new Date().toISOString(); - const later = new Date(Date.parse(now) + 1_000).toISOString(); + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-01T00:00:01.000Z"; const created = await Effect.runPromise( projectEvent( createEmptyReadModel(now), @@ -150,7 +151,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", @@ -205,7 +206,7 @@ describe("orchestration projector", () => { }); it("keeps projector forward-compatible for unhandled event types", async () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); const next = await Effect.runPromise( @@ -253,7 +254,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -319,7 +320,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -376,7 +377,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -463,7 +464,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -678,7 +679,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5.3-codex", }, runtimeMode: "full-access", @@ -831,7 +832,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "capped", modelSelection: { - provider: "codex", + provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d7..0c92f965433 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -5,7 +5,8 @@ import { OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { @@ -46,15 +47,14 @@ function updateThread( } function decodeForEvent( - schema: Schema.Schema, + schema: Schema.Decoder, value: unknown, eventType: OrchestrationEvent["type"], field: string, ): Effect.Effect { - return Effect.try({ - try: () => Schema.decodeUnknownSync(schema as any)(value), - catch: (error) => toProjectorDecodeError(`${eventType}:${field}`)(error as Schema.SchemaError), - }); + return Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError(toProjectorDecodeError(`${eventType}:${field}`)), + ); } function retainThreadMessagesAfterRevert( diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index 1b964709aec..a2ed5875950 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -1,4 +1,4 @@ -import { Layer } from "effect"; +import * as Layer from "effect/Layer"; import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts deleted file mode 100644 index ca03ab58682..00000000000 --- a/apps/server/src/os-jank.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { fixPath } from "./os-jank"; - -describe("fixPath", () => { - it("hydrates PATH on linux using the resolved login shell", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); - - fixPath({ - env, - platform: "linux", - readPath, - }); - - expect(readPath).toHaveBeenCalledWith("/bin/zsh"); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("does nothing outside macOS and linux even when SHELL is set", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "C:/Program Files/Git/bin/bash.exe", - PATH: "C:\\Windows\\System32", - }; - const readPath = vi.fn(() => "/usr/local/bin:/usr/bin"); - - fixPath({ - env, - platform: "win32", - readPath, - }); - - expect(readPath).not.toHaveBeenCalled(); - expect(env.PATH).toBe("C:\\Windows\\System32"); - }); -}); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index c3629e8fdeb..93a40ae7e19 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,38 +1,96 @@ -import * as OS from "node:os"; -import { Effect, Path } from "effect"; -import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import { + readPathFromLoginShell, + readEnvironmentFromWindowsShell, + resolveWindowsEnvironment, + type CommandAvailabilityOptions, + type WindowsShellEnvironmentReader, + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, +} from "@t3tools/shared/shell"; + +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + +function logPathHydrationWarning(message: string, error?: unknown): void { + process.stderr.write( + `[server] ${message} ${error instanceof Error ? error.message : (error ?? "")}\n`, + ); +} export function fixPath( options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; - const env = options.env ?? process.env; + const logWarning = options.logWarning ?? logPathHydrationWarning; + const readPath = options.readPath ?? readPathFromLoginShell; try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; - const result = (options.readPath ?? readPathFromLoginShell)(shell); - if (result) { - env.PATH = result; + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + shellPath = readPath(shell); + } catch (error) { + logWarning(`Failed to read PATH from login shell ${shell}.`, error); + } + + if (shellPath) { + break; + } + } + + const launchctlPath = + platform === "darwin" && !shellPath + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } - } catch { - // Silently ignore — keep default PATH + } catch (error) { + logWarning("Failed to hydrate PATH from the user environment.", error); } } export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return join(OS.homedir(), input.slice(2)); + return join(NodeOS.homedir(), input.slice(2)); } return input; }); @@ -40,7 +98,7 @@ export const expandHomePath = Effect.fn(function* (input: string) { export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; if (!raw || raw.trim().length === 0) { - return join(OS.homedir(), ".t3"); + return join(NodeOS.homedir(), ".t3"); } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/pathExpansion.test.ts b/apps/server/src/pathExpansion.test.ts new file mode 100644 index 00000000000..c195490eed9 --- /dev/null +++ b/apps/server/src/pathExpansion.test.ts @@ -0,0 +1,34 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { expandHomePath } from "./pathExpansion.ts"; + +describe("expandHomePath", () => { + it("returns an empty string unchanged", () => { + expect(expandHomePath("")).toBe(""); + }); + + it("returns paths without a leading tilde unchanged", () => { + expect(expandHomePath("/absolute/path")).toBe("/absolute/path"); + expect(expandHomePath("relative/path")).toBe("relative/path"); + expect(expandHomePath("some~weird~path")).toBe("some~weird~path"); + }); + + it("expands a lone tilde to the home directory", () => { + expect(expandHomePath("~")).toBe(homedir()); + }); + + it("expands ~/ to a subpath of the home directory", () => { + expect(expandHomePath("~/.codex-work")).toBe(join(homedir(), ".codex-work")); + }); + + it("expands a Windows-style ~\\ prefix", () => { + expect(expandHomePath("~\\.codex")).toBe(join(homedir(), ".codex")); + }); + + it("does not expand ~user paths", () => { + expect(expandHomePath("~alice/foo")).toBe("~alice/foo"); + }); +}); diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts new file mode 100644 index 00000000000..170d83c54d0 --- /dev/null +++ b/apps/server/src/pathExpansion.ts @@ -0,0 +1,24 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the + * current user's home directory. Spawned processes don't get shell + * expansion, so env vars like `CODEX_HOME=~/.codex-work` would be passed + * verbatim and treated as relative paths by the receiver. + * + * Matches the behavior of the other `expandHomePath` helpers in the + * workspace layers and CLI bootstrap: `~` alone and both `~/` and `~\` + * separators are handled. Returns the input unchanged if it doesn't + * start with `~` or is empty. Does not handle `~user` (other-user) + * expansion. + */ +export function expandHomePath(value: string): string { + if (!value) return value; + if (value === "~") return homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return join(homedir(), value.slice(2)); + } + return value; +} diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index eb05bf5ae95..2b702eba5ea 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,4 +1,5 @@ -import { Schema, SchemaIssue } from "effect"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; // =============================== // Core Persistence Errors @@ -29,6 +30,8 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass @@ -58,7 +61,7 @@ export function toPersistenceDecodeCauseError(operation: string) { } export const isPersistenceError = (u: unknown) => - Schema.is(PersistenceSqlError)(u) || Schema.is(PersistenceDecodeError)(u); + isPersistenceSqlError(u) || isPersistenceDecodeError(u); // =============================== // Provider Session Repository Errors diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts index 9767f24993a..15c572ad0a1 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -1,6 +1,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { toPersistenceDecodeError, diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts index 66e02ed2a72..64b8146f927 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -1,7 +1,10 @@ import { AuthSessionId } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; +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 { toPersistenceDecodeError, diff --git a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts index 33f28567428..989f5d78f8d 100644 --- a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts @@ -1,6 +1,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { toPersistenceSqlError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 80526986a70..2bac5de920c 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -1,12 +1,16 @@ import { CommandId, EventId, ProjectId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Effect, Layer, Schema, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { PersistenceDecodeError } from "../Errors.ts"; import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; +const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); const layer = it.layer( OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), @@ -17,7 +21,7 @@ layer("OrchestrationEventStore", (it) => { Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const appended = yield* eventStore.append({ type: "project.created", @@ -69,7 +73,7 @@ layer("OrchestrationEventStore", (it) => { Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; yield* sql` INSERT INTO orchestration_events ( @@ -107,7 +111,7 @@ layer("OrchestrationEventStore", (it) => { ); assert.equal(replayResult._tag, "Failure"); if (replayResult._tag === "Failure") { - assert.ok(Schema.is(PersistenceDecodeError)(replayResult.failure)); + assert.ok(isPersistenceDecodeError(replayResult.failure)); assert.ok( replayResult.failure.operation.includes( "OrchestrationEventStore.readFromSequence:decodeRows", diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 4d81cf5e8d7..18d0e9aa578 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -13,7 +13,10 @@ import { } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { toPersistenceDecodeError, diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts index 26ef8fed1b9..cfa5658d0e9 100644 --- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -1,7 +1,11 @@ import { OrchestrationCheckpointFile } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +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 Struct from "effect/Struct"; import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 25443a70dca..253f6e13b97 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -1,6 +1,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { toPersistenceSqlError } from "../Errors.ts"; import { diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7ff19f55aea..c1ca6d3104e 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,6 +1,9 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import { ModelSelection, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 08bc2481226..a2069e62a14 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,6 +1,8 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; @@ -28,7 +30,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { title: "Null options project", workspaceRoot: "/tmp/project-null-options", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, scripts: [], @@ -46,13 +48,14 @@ projectionRepositoriesLayer("Projection repositories", (it) => { `; const row = rows[0]; if (!row) { - return yield* Effect.fail(new Error("Expected projection_projects row to exist.")); + return yield* Effect.die("Expected projection_projects row to exist."); } assert.strictEqual( row.defaultModelSelection, + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }), ); @@ -61,7 +64,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.make("project-null-options"), }); assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }); }), @@ -77,7 +80,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.make("project-null-options"), title: "Null options thread", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, runtimeMode: "full-access", @@ -88,6 +91,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); @@ -100,13 +107,14 @@ projectionRepositoriesLayer("Projection repositories", (it) => { `; const row = rows[0]; if (!row) { - return yield* Effect.fail(new Error("Expected projection_threads row to exist.")); + return yield* Effect.die("Expected projection_threads row to exist."); } assert.strictEqual( row.modelSelection, + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }), ); @@ -115,7 +123,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { threadId: ThreadId.make("thread-null-options"), }); assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }); }), diff --git a/apps/server/src/persistence/Layers/ProjectionState.ts b/apps/server/src/persistence/Layers/ProjectionState.ts index 7c2b8110c70..f630fe51e03 100644 --- a/apps/server/src/persistence/Layers/ProjectionState.ts +++ b/apps/server/src/persistence/Layers/ProjectionState.ts @@ -1,7 +1,9 @@ import { NonNegativeInt } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { toPersistenceSqlError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 8e88cfa7853..2f4815f9654 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -1,7 +1,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { NonNegativeInt } from "@t3tools/contracts"; -import { Effect, Layer, Schema, Struct } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 8bbe723bf37..b1f394a9e57 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -1,6 +1,7 @@ import { MessageId, ThreadId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 13b7086cecd..71919166886 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1,6 +1,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +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 Struct from "effect/Struct"; import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index ccd322feb23..63aed1a1670 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -1,4 +1,5 @@ -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 2499eba1967..dcb750983a0 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -1,6 +1,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { toPersistenceSqlError } from "../Errors.ts"; @@ -23,6 +24,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id, status, provider_name, + provider_instance_id, runtime_mode, active_turn_id, last_error, @@ -32,6 +34,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.threadId}, ${row.status}, ${row.providerName}, + ${row.providerInstanceId}, ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, @@ -41,6 +44,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { DO UPDATE SET status = excluded.status, provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, @@ -57,6 +61,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id AS "threadId", status, provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 48dd51fdcab..1baeb375c15 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,6 +1,9 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -40,6 +43,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, deleted_at ) VALUES ( @@ -55,6 +62,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.latestUserMessageAt}, + ${row.pendingApprovalCount}, + ${row.pendingUserInputCount}, + ${row.hasActionableProposedPlan}, ${row.deletedAt} ) ON CONFLICT (thread_id) @@ -70,6 +81,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + latest_user_message_at = excluded.latest_user_message_at, + pending_approval_count = excluded.pending_approval_count, + pending_user_input_count = excluded.pending_user_input_count, + has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, deleted_at = excluded.deleted_at `, }); @@ -92,6 +107,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} @@ -116,6 +135,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE project_id = ${projectId} diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index 9b6c9c57710..bd57a4eaa30 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -1,7 +1,11 @@ import { OrchestrationCheckpointFile } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +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 Struct from "effect/Struct"; import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index da3e8bce90a..9ee5c82bb53 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -1,7 +1,11 @@ import { ThreadId } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +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 Struct from "effect/Struct"; import { toPersistenceDecodeError, @@ -46,6 +50,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { INSERT INTO provider_session_runtime ( thread_id, provider_name, + provider_instance_id, adapter_key, runtime_mode, status, @@ -56,6 +61,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { VALUES ( ${runtime.threadId}, ${runtime.providerName}, + ${runtime.providerInstanceId}, ${runtime.adapterKey}, ${runtime.runtimeMode}, ${runtime.status}, @@ -66,6 +72,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ON CONFLICT (thread_id) DO UPDATE SET provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, adapter_key = excluded.adapter_key, runtime_mode = excluded.runtime_mode, status = excluded.status, @@ -83,6 +90,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", adapter_key AS "adapterKey", runtime_mode AS "runtimeMode", status, @@ -102,6 +110,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", adapter_key AS "adapterKey", runtime_mode AS "runtimeMode", status, diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 58556099db1..3bc1ec4d2d2 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -1,4 +1,7 @@ -import { Effect, Layer, FileSystem, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8c9fe4d9fd1..cc5024d5f51 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -35,6 +35,14 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; +import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; +import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; +import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; +import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; +import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; +import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; +import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; +import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -69,6 +77,14 @@ export const migrationEntries = [ [20, "AuthAccessManagement", Migration0020], [21, "AuthSessionClientMetadata", Migration0021], [22, "AuthSessionLastConnectedAt", Migration0022], + [23, "ProjectionThreadShellSummary", Migration0023], + [24, "BackfillProjectionThreadShellSummary", Migration0024], + [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], + [26, "CanonicalizeModelSelectionOptions", Migration0026], + [27, "ProviderSessionRuntimeInstanceId", Migration0027], + [28, "ProjectionThreadSessionInstanceId", Migration0028], + [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], + [30, "ProjectionThreadShellArchiveIndexes", Migration0030], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index d74e9fe08cd..1e64519ff4f 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; @@ -264,7 +265,7 @@ layer("016_CanonicalizeModelSelections", (it) => { FROM orchestration_events ORDER BY rowid ASC `; - + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), { projectId: "project-1", title: "Project", @@ -280,7 +281,7 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { projectId: "project-2", title: "Fallback Project", @@ -296,7 +297,7 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { projectId: "project-3", title: "Null Model Project", @@ -306,7 +307,7 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), { threadId: "thread-1", projectId: "project-1", @@ -326,7 +327,7 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), { threadId: "thread-2", projectId: "project-1", @@ -345,7 +346,7 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), { threadId: "thread-1", turnId: "turn-1", @@ -359,7 +360,7 @@ layer("016_CanonicalizeModelSelections", (it) => { }, deliveryMode: "buffered", }); - + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[6]!.payloadJson), { threadId: "thread-3", projectId: "project-1", diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts index 6207a9bcb6a..2011613a9f9 100644 --- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts +++ b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; diff --git a/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts new file mode 100644 index 00000000000..759f87e8ad7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts @@ -0,0 +1,26 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN latest_user_message_at TEXT + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_approval_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_user_input_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); +}); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts new file mode 100644 index 00000000000..71dfe6fd004 --- /dev/null +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts @@ -0,0 +1,219 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("024_BackfillProjectionThreadShellSummary", (it) => { + it.effect("backfills thread shell summary fields and clears stale projected approvals", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 23 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'plan', + NULL, + NULL, + 'turn-1', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z', + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'message-user-1', + 'thread-1', + 'turn-1', + 'user', + 'Need help', + NULL, + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-1', + 'turn-1', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-1","requestKind":"command"}', + NULL, + '2026-02-24T00:02:00.000Z' + ), + ( + 'activity-approval-stale', + 'thread-1', + 'turn-1', + 'error', + 'provider.approval.respond.failed', + 'Provider approval response failed', + '{"requestId":"approval-1","detail":"Unknown pending permission request: approval-1"}', + NULL, + '2026-02-24T00:03:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-1', + 'turn-1', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-1","questions":[{"id":"area","header":"Area","question":"Which repo area should I inspect next?","options":[{"label":"Server","description":"Server orchestration."}]}]}', + NULL, + '2026-02-24T00:04:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES ( + 'plan-1', + 'thread-1', + 'turn-1', + '# Do the thing', + NULL, + NULL, + '2026-02-24T00:05:00.000Z', + '2026-02-24T00:05:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + 'approval-1', + 'thread-1', + 'turn-1', + 'pending', + NULL, + '2026-02-24T00:02:00.000Z', + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 24 }); + + const threadRows = yield* sql<{ + readonly latestUserMessageAt: string | null; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly hasActionableProposedPlan: number; + }>` + SELECT + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepStrictEqual(threadRows, [ + { + latestUserMessageAt: "2026-02-24T00:01:00.000Z", + pendingApprovalCount: 0, + pendingUserInputCount: 1, + hasActionableProposedPlan: 1, + }, + ]); + + const approvalRows = yield* sql<{ + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-1' + `; + assert.deepStrictEqual(approvalRows, [ + { + status: "resolved", + resolvedAt: "2026-02-24T00:03:00.000Z", + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts new file mode 100644 index 00000000000..549906dfb03 --- /dev/null +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts @@ -0,0 +1,277 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT OR IGNORE INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + SELECT + requested.request_id, + requested.thread_id, + requested.turn_id, + 'pending', + NULL, + requested.created_at, + NULL + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + thread_id, + turn_id, + created_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at ASC, activity_id ASC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS requested + WHERE requested.row_number = 1 + `; + + yield* sql` + WITH latest_resolutions AS ( + SELECT + resolved.request_id, + resolved.resolved_at, + resolved.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.resolved' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS resolved + WHERE resolved.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_resolutions.decision + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_resolutions.resolved_at + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_response_events AS ( + SELECT + response.request_id, + response.resolved_at, + response.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + occurred_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY occurred_at DESC, sequence DESC + ) AS row_number + FROM orchestration_events + WHERE event_type = 'thread.approval-response-requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS response + WHERE response.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_response_events.decision + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_response_events.resolved_at + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_stale_failures AS ( + SELECT + failure.request_id, + failure.resolved_at + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'provider.approval.respond.failed' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + AND ( + lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%stale pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending permission request%' + ) + ) AS failure + WHERE failure.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = NULL, + resolved_at = ( + SELECT latest_stale_failures.resolved_at + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + WHERE status = 'pending' + AND EXISTS ( + SELECT 1 + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET + latest_user_message_at = ( + SELECT MAX(message.created_at) + FROM projection_thread_messages AS message + WHERE message.thread_id = projection_threads.thread_id + AND message.role = 'user' + ), + pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0), + pending_user_input_count = COALESCE(( + WITH latest_user_input_states AS ( + SELECT + latest.request_id, + latest.kind, + latest.detail + FROM ( + SELECT + json_extract(activity.payload_json, '$.requestId') AS request_id, + activity.kind, + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS row_number + FROM projection_thread_activities AS activity + WHERE activity.thread_id = projection_threads.thread_id + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + AND activity.kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ) AS latest + WHERE latest.row_number = 1 + ) + SELECT COUNT(*) + FROM latest_user_input_states + WHERE latest_user_input_states.kind = 'user-input.requested' + OR ( + latest_user_input_states.kind = 'provider.user-input.respond.failed' + AND latest_user_input_states.detail NOT LIKE '%stale pending user-input request%' + AND latest_user_input_states.detail NOT LIKE '%unknown pending user-input request%' + ) + ), 0), + has_actionable_proposed_plan = COALESCE(( + SELECT CASE + WHEN projection_threads.latest_turn_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS latest_turn_plan_exists + WHERE latest_turn_plan_exists.thread_id = projection_threads.thread_id + AND latest_turn_plan_exists.turn_id = projection_threads.latest_turn_id + ) + THEN CASE + WHEN ( + SELECT latest_turn_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_turn_plan + WHERE latest_turn_plan.thread_id = projection_threads.thread_id + AND latest_turn_plan.turn_id = projection_threads.latest_turn_id + ORDER BY latest_turn_plan.updated_at DESC, latest_turn_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + WHEN EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS any_plan + WHERE any_plan.thread_id = projection_threads.thread_id + ) + THEN CASE + WHEN ( + SELECT latest_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_plan + WHERE latest_plan.thread_id = projection_threads.thread_id + ORDER BY latest_plan.updated_at DESC, latest_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + ELSE 0 + END + ), 0) + `; +}); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts new file mode 100644 index 00000000000..752b1676efa --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts @@ -0,0 +1,197 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("025_CleanupInvalidProjectionPendingApprovals", (it) => { + it.effect("removes pending-approval rows that do not come from approval requests", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 24 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES + ( + 'thread-valid', + 'project-1', + 'Valid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-valid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 2, + 0, + 0, + NULL + ), + ( + 'thread-invalid', + 'project-1', + 'Invalid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-invalid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 1, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-valid', + 'turn-valid', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-valid","requestKind":"command"}', + NULL, + '2026-04-13T00:01:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-invalid', + 'turn-invalid', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-invalid","questions":[{"id":"scope","header":"Scope","question":"What should I inspect?","options":[{"label":"Server","description":"Inspect server code."}]}]}', + NULL, + '2026-04-13T00:02:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES + ( + 'approval-valid', + 'thread-valid', + 'turn-valid', + 'pending', + NULL, + '2026-04-13T00:01:00.000Z', + NULL + ), + ( + 'input-invalid', + 'thread-invalid', + 'turn-invalid', + 'pending', + NULL, + '2026-04-13T00:02:00.000Z', + NULL + ), + ( + 'input-invalid-resolved', + 'thread-valid', + 'turn-valid', + 'resolved', + NULL, + '2026-04-13T00:03:00.000Z', + '2026-04-13T00:04:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 25 }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + }>` + SELECT + request_id AS "requestId", + status + FROM projection_pending_approvals + ORDER BY request_id ASC + `; + assert.deepStrictEqual(approvalRows, [ + { + requestId: "approval-valid", + status: "pending", + }, + ]); + + const threadCounts = yield* sql<{ + readonly threadId: string; + readonly pendingApprovalCount: number; + }>` + SELECT + thread_id AS "threadId", + pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + ORDER BY thread_id ASC + `; + assert.deepStrictEqual(threadCounts, [ + { + threadId: "thread-invalid", + pendingApprovalCount: 0, + }, + { + threadId: "thread-valid", + pendingApprovalCount: 1, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts new file mode 100644 index 00000000000..33a6512c750 --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts @@ -0,0 +1,27 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + DELETE FROM projection_pending_approvals + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_thread_activities AS activity + WHERE activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') + = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0) + `; +}); diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts new file mode 100644 index 00000000000..5160b4ab34b --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -0,0 +1,451 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("026_CanonicalizeModelSelectionOptions", (it) => { + it.effect("converts legacy object-shape options into array-shape on projections and events", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 25 }); + + 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-legacy', + 'Legacy options project', + '/tmp/legacy', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-no-options', + 'No options project', + '/tmp/no-options', + '{"provider":"codex","model":"gpt-5.4"}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-null-selection', + 'Null model selection project', + '/tmp/null-selection', + NULL, + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ), + ( + 'project-already-array', + 'Already-canonical options project', + '/tmp/already-array', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"high"}]}', + '[]', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at, + runtime_mode, + interaction_mode + ) + VALUES + ( + 'thread-legacy', + 'project-legacy', + 'Legacy thread', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false,"contextWindow":"1m"}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-empty-options', + 'project-legacy', + 'Empty options thread', + '{"provider":"codex","model":"gpt-5.4","options":{}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-drop-garbage', + 'project-legacy', + 'Thread with non-scalar entries', + '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","thinking":{"enabled":true,"budgetTokens":2000},"emptyStr":" ","nullish":null}}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-no-options', + 'project-legacy', + 'No options thread', + '{"provider":"codex","model":"gpt-5.4"}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ), + ( + 'thread-already-array', + 'project-legacy', + 'Already array thread', + '{"provider":"codex","model":"gpt-5.4","options":[{"id":"fastMode","value":true}]}', + NULL, NULL, NULL, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + NULL, NULL, 0, 0, 0, NULL, + 'full-access', 'default' + ) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES + ( + 'event-project-created', + 'project', + 'project-legacy', + 1, + 'project.created', + '2026-01-01T00:00:00.000Z', + 'cmd-pc', + NULL, + 'corr-pc', + 'user', + '{"projectId":"project-legacy","title":"Project","workspaceRoot":"/tmp/legacy","defaultModelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-meta-updated', + 'project', + 'project-legacy', + 2, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-pmu', + NULL, + 'corr-pmu', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":{"provider":"codex","model":"gpt-5.4","options":{"reasoningEffort":"low"}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-project-null-selection', + 'project', + 'project-legacy', + 3, + 'project.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-null', + NULL, + 'corr-null', + 'user', + '{"projectId":"project-legacy","defaultModelSelection":null,"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-created', + 'thread', + 'thread-legacy', + 1, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-tc', + NULL, + 'corr-tc', + 'user', + '{"threadId":"thread-legacy","projectId":"project-legacy","title":"Thread","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-meta-updated', + 'thread', + 'thread-legacy', + 2, + 'thread.meta-updated', + '2026-01-01T00:00:00.000Z', + 'cmd-tmu', + NULL, + 'corr-tmu', + 'user', + '{"threadId":"thread-legacy","modelSelection":{"provider":"codex","model":"gpt-5.4","options":{"fastMode":true}},"updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-turn-start', + 'thread', + 'thread-legacy', + 3, + 'thread.turn-start-requested', + '2026-01-01T00:00:00.000Z', + 'cmd-tts', + NULL, + 'corr-tts', + 'user', + '{"threadId":"thread-legacy","messageId":"msg-1","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","contextWindow":"1m"}},"runtimeMode":"full-access","interactionMode":"default","createdAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-thread-already-array', + 'thread', + 'thread-legacy', + 4, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'cmd-taa', + NULL, + 'corr-taa', + 'user', + '{"threadId":"thread-already-array","projectId":"project-legacy","title":"Already Array","modelSelection":{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"medium"}]},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), + ( + 'event-activity-append', + 'thread', + 'thread-legacy', + 5, + 'thread.activity-appended', + '2026-01-01T00:00:00.000Z', + 'cmd-aa', + NULL, + 'corr-aa', + 'user', + '{"threadId":"thread-legacy","activity":{"id":"a","tone":"info","kind":"k","summary":"s","payload":null,"turnId":null,"createdAt":"2026-01-01T00:00:00.000Z"}}', + '{}' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 26 }); + + // Projection projects + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultModelSelection: string | null; + }>` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection" + FROM projection_projects + ORDER BY project_id + `; + assert.deepStrictEqual( + projectRows.map((row) => ({ + projectId: row.projectId, + selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null, + })), + [ + { + projectId: "project-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }, + { + projectId: "project-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }, + }, + { + projectId: "project-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + { projectId: "project-null-selection", selection: null }, + ], + ); + + // Projection threads + const threadRows = yield* sql<{ + readonly threadId: string; + readonly modelSelection: string | null; + }>` + SELECT + thread_id AS "threadId", + model_selection_json AS "modelSelection" + FROM projection_threads + ORDER BY thread_id + `; + assert.deepStrictEqual( + threadRows.map((row) => ({ + threadId: row.threadId, + selection: row.modelSelection ? JSON.parse(row.modelSelection) : null, + })), + [ + { + threadId: "thread-already-array", + selection: { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }, + }, + { + threadId: "thread-drop-garbage", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + // Only the scalar string survives; nested object, whitespace + // string, and null are dropped. + options: [{ id: "effort", value: "high" }], + }, + }, + { + threadId: "thread-empty-options", + selection: { provider: "codex", model: "gpt-5.4", options: [] }, + }, + { + threadId: "thread-legacy", + selection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + { id: "contextWindow", value: "1m" }, + ], + }, + }, + { + threadId: "thread-no-options", + selection: { provider: "codex", model: "gpt-5.4" }, + }, + ], + ); + + // Orchestration events + const eventRows = yield* sql<{ + readonly eventId: string; + readonly payloadJson: string; + }>` + SELECT event_id AS "eventId", payload_json AS "payloadJson" + FROM orchestration_events + ORDER BY event_id + `; + + const payloads = Object.fromEntries( + eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]), + ); + + assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + }); + + assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "low" }], + }); + + assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null); + + assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "thinking", value: false }, + ], + }); + + assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "fastMode", value: true }], + }); + + assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "high" }, + { id: "contextWindow", value: "1m" }, + ], + }); + + // Already-array records are left untouched. + assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, { + provider: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + }); + + // Events with no modelSelection at all are untouched. + assert.isUndefined(payloads["event-activity-append"].modelSelection); + assert.isUndefined(payloads["event-activity-append"].defaultModelSelection); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts new file mode 100644 index 00000000000..15c08debf64 --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts @@ -0,0 +1,138 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Canonicalize `modelSelection.options` / `defaultModelSelection.options` from + * the legacy object shape (`{ effort: "max", fastMode: true, ... }`) to the + * current array-of-selections shape (`[{ id: "effort", value: "max" }, ...]`). + * + * Migration 016 introduced `modelSelection` with `options` stored as a + * per-provider object. Later the schema was reshaped so that options are a + * generic `Array<{ id, value }>` of user-selected option entries. Stored rows + * from before the reshape still have the object shape and fail to decode. + * + * For each value in the legacy object: + * - string values are kept if non-empty after trim + * - boolean values are always kept (true | false) + * - any other value type (number, null, nested object/array) is dropped, + * matching the permissive client-side normalizer in composerDraftStore. + * + * Touched storage: + * - `projection_threads.model_selection_json.options` + * - `projection_projects.default_model_selection_json.options` + * - `orchestration_events.payload_json.$.modelSelection.options` + * (thread.created | thread.meta-updated | thread.turn-start-requested) + * - `orchestration_events.payload_json.$.defaultModelSelection.options` + * (project.created | project.meta-updated) + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET model_selection_json = json_set( + model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE model_selection_json IS NOT NULL + AND json_type(model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE projection_projects + SET default_model_selection_json = json_set( + default_model_selection_json, + '$.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(default_model_selection_json, '$.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE default_model_selection_json IS NOT NULL + AND json_type(default_model_selection_json, '$.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.modelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.modelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ( + 'thread.created', + 'thread.meta-updated', + 'thread.turn-start-requested' + ) + AND json_type(payload_json, '$.modelSelection.options') = 'object' + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.defaultModelSelection.options', + ( + SELECT json_group_array( + json_object( + 'id', key, + 'value', + CASE type + WHEN 'true' THEN json('true') + WHEN 'false' THEN json('false') + ELSE atom + END + ) + ) + FROM json_each(json_extract(payload_json, '$.defaultModelSelection.options')) + WHERE (type = 'text' AND trim(coalesce(atom, '')) != '') + OR type IN ('true', 'false') + ) + ) + WHERE event_type IN ('project.created', 'project.meta-updated') + AND json_type(payload_json, '$.defaultModelSelection.options') = 'object' + `; +}); diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts new file mode 100644 index 00000000000..5c0d7e2a7a8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -0,0 +1,75 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("027_028_ProviderInstanceIdColumns", (it) => { + it.effect("continues when provider_session_runtime was partially migrated", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 26 }); + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN provider_instance_id TEXT + `; + + yield* runMigrations({ toMigrationInclusive: 28 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id IN (27, 28) + ORDER BY migration_id + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 27, + name: "ProviderSessionRuntimeInstanceId", + }, + { + migration_id: 28, + name: "ProjectionThreadSessionInstanceId", + }, + ]); + + const providerSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(provider_session_runtime) + `; + assert.ok(providerSessionColumns.some((column) => column.name === "provider_instance_id")); + + const projectionThreadSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionColumns.some((column) => column.name === "provider_instance_id"), + ); + + const providerSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(provider_session_runtime) + `; + assert.ok( + providerSessionIndexes.some( + (index) => index.name === "idx_provider_session_runtime_instance", + ), + ); + + const projectionThreadSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionIndexes.some( + (index) => index.name === "idx_projection_thread_sessions_instance", + ), + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts b/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts new file mode 100644 index 00000000000..7ae0e55b13c --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts @@ -0,0 +1,38 @@ +/** + * Adds the nullable `provider_instance_id` routing column to + * `provider_session_runtime`. + * + * Slice D of the provider-array refactor splits "driver kind" from + * "configured instance". Existing rows have only the driver name in + * `provider_name`; new rows additionally carry the user-defined instance + * routing key. The column remains nullable so legacy rows can still decode; + * the persistence boundary is responsible for materializing a concrete + * instance id before any hot routing path sees the binding. + * + * The column is nullable on purpose — backfilling it during the migration + * would require knowing which configured instance "owned" each historical + * session, and that mapping is ambiguous when the user later configures + * multiple instances of the same driver. Keeping that compatibility at the + * persistence boundary keeps the fallback out of active routing code. + */ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(provider_session_runtime) + `; + if (!columns.some((column) => column.name === "provider_instance_id")) { + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN provider_instance_id TEXT + `; + } + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_instance + ON provider_session_runtime(provider_instance_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts b/apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts new file mode 100644 index 00000000000..bc4ba98044f --- /dev/null +++ b/apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts @@ -0,0 +1,21 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + if (!columns.some((column) => column.name === "provider_instance_id")) { + yield* sql` + ALTER TABLE projection_thread_sessions + ADD COLUMN provider_instance_id TEXT + `; + } + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_instance + ON projection_thread_sessions(provider_instance_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts new file mode 100644 index 00000000000..4b0aa186cb4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts @@ -0,0 +1,74 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("029_ProjectionThreadDetailOrderingIndexes", (it) => { + it.effect("creates indexes matching thread detail ordering queries", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 28 }); + yield* runMigrations({ toMigrationInclusive: 29 }); + + const activityIndexes = yield* sql<{ + readonly seq: number; + readonly name: string; + readonly unique: number; + readonly origin: string; + readonly partial: number; + }>` + PRAGMA index_list(projection_thread_activities) + `; + assert.ok( + activityIndexes.some( + (index) => index.name === "idx_projection_thread_activities_thread_sequence_created_id", + ), + ); + + const activityIndexColumns = yield* sql<{ + readonly seqno: number; + readonly cid: number; + readonly name: string; + }>` + PRAGMA index_info('idx_projection_thread_activities_thread_sequence_created_id') + `; + assert.deepStrictEqual( + activityIndexColumns.map((column) => column.name), + ["thread_id", "sequence", "created_at", "activity_id"], + ); + + const messageIndexes = yield* sql<{ + readonly seq: number; + readonly name: string; + readonly unique: number; + readonly origin: string; + readonly partial: number; + }>` + PRAGMA index_list(projection_thread_messages) + `; + assert.ok( + messageIndexes.some( + (index) => index.name === "idx_projection_thread_messages_thread_created_id", + ), + ); + + const messageIndexColumns = yield* sql<{ + readonly seqno: number; + readonly cid: number; + readonly name: string; + }>` + PRAGMA index_info('idx_projection_thread_messages_thread_created_id') + `; + assert.deepStrictEqual( + messageIndexColumns.map((column) => column.name), + ["thread_id", "created_at", "message_id"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.ts b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.ts new file mode 100644 index 00000000000..4a0595afa9b --- /dev/null +++ b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_sequence_created_id + ON projection_thread_activities(thread_id, sequence, created_at, activity_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created_id + ON projection_thread_messages(thread_id, created_at, message_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts b/apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts new file mode 100644 index 00000000000..3b7bf51f04b --- /dev/null +++ b/apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_shell_active + ON projection_threads(deleted_at, archived_at, project_id, created_at, thread_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_shell_archived + ON projection_threads(deleted_at, archived_at, project_id, thread_id) + `; +}); diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/apps/server/src/persistence/NodeSqliteClient.test.ts index a055d409ba2..43023abf60a 100644 --- a/apps/server/src/persistence/NodeSqliteClient.test.ts +++ b/apps/server/src/persistence/NodeSqliteClient.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqliteClient from "./NodeSqliteClient.ts"; diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts index d03e67cbb5b..c81ce51a3f4 100644 --- a/apps/server/src/persistence/Services/AuthPairingLinks.ts +++ b/apps/server/src/persistence/Services/AuthPairingLinks.ts @@ -1,5 +1,7 @@ -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts index 567d3cca5c1..a92573f9f9e 100644 --- a/apps/server/src/persistence/Services/AuthSessions.ts +++ b/apps/server/src/persistence/Services/AuthSessions.ts @@ -1,6 +1,8 @@ import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { AuthSessionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index da8755ae71c..1498984827e 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -15,8 +15,10 @@ import { ProjectId, ThreadId, } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index beecdcdc656..8b465e7713e 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -10,8 +10,9 @@ * @module OrchestrationEventStore */ import { OrchestrationEvent } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect, Stream } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; import type { OrchestrationEventStoreError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts index 617f964f333..7796ebec2a8 100644 --- a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts @@ -16,8 +16,10 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Context, Schema } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import * as Schema from "effect/Schema"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 82c43c0bb03..967e6da9d3a 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -14,8 +14,10 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 0970bb2ead4..5632205a269 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -7,8 +7,10 @@ * @module ProjectionProjectRepository */ import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionState.ts b/apps/server/src/persistence/Services/ProjectionState.ts index 0b75b817d25..ba1ca151736 100644 --- a/apps/server/src/persistence/Services/ProjectionState.ts +++ b/apps/server/src/persistence/Services/ProjectionState.ts @@ -7,8 +7,10 @@ * @module ProjectionStateRepository */ import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 87daa3c636b..47cb6073c47 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -14,8 +14,9 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 56f8f92dbfe..d50ff320256 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -14,9 +14,10 @@ import { TurnId, IsoDateTime, } from "@t3tools/contracts"; -import { Schema, Context } from "effect"; -import type { Option } from "effect"; -import type { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Option from "effect/Option"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index a68bedb8c37..b4bc2bcc328 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -5,8 +5,9 @@ import { TrimmedNonEmptyString, TurnId, } from "@t3tools/contracts"; -import { Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index fcd13f068da..7cecac33eb6 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -10,11 +10,14 @@ import { RuntimeMode, IsoDateTime, OrchestrationSessionStatus, + ProviderInstanceId, ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -22,6 +25,7 @@ export const ProjectionThreadSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, providerName: Schema.NullOr(Schema.String), + providerInstanceId: Schema.NullOr(ProviderInstanceId), runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 31ec00a6e5e..44fdc147a4a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,14 +9,17 @@ import { IsoDateTime, ModelSelection, + NonNegativeInt, ProjectId, ProviderInteractionMode, RuntimeMode, ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -33,6 +36,10 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + pendingApprovalCount: NonNegativeInt, + pendingUserInputCount: NonNegativeInt, + hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index 8e37ae09bd9..f3d5d5e4706 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -17,8 +17,10 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts index bf8e658e8a6..125f4fa5bbf 100644 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -7,18 +7,29 @@ */ import { IsoDateTime, + ProviderInstanceId, ProviderSessionRuntimeStatus, RuntimeMode, ThreadId, } from "@t3tools/contracts"; -import { Option, Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; export const ProviderSessionRuntime = Schema.Struct({ threadId: ThreadId, providerName: Schema.String, + /** + * User-defined routing key for the configured provider instance that + * owns this session. Nullable only at the storage/migration boundary: + * rows persisted before the driver/instance split carry only + * `providerName`. Repository consumers must materialize a concrete + * instance id before routing. + */ + providerInstanceId: Schema.NullOr(ProviderInstanceId), adapterKey: Schema.String, runtimeMode: RuntimeMode, status: ProviderSessionRuntimeStatus, diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts new file mode 100644 index 00000000000..151600c47d9 --- /dev/null +++ b/apps/server/src/process/externalLauncher.test.ts @@ -0,0 +1,802 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { assertSuccess } from "@effect/vitest/utils"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + isCommandAvailable, + launchBrowser, + launchEditorProcess, + resolveAvailableEditors, + resolveBrowserLaunch, + resolveEditorLaunch, +} from "./externalLauncher.ts"; + +function encodeUtf16LeBase64(input: string): string { + const bytes = new Uint8Array(input.length * 2); + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + bytes[index * 2] = code & 0xff; + bytes[index * 2 + 1] = code >>> 8; + } + return Encoding.encodeBase64(bytes); +} + +function makeMockDetachedHandle(onUnref: () => void = () => undefined) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.sync(() => { + onUnref(); + return Effect.void; + }), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { + it.effect("returns commands for command-based editors", () => + Effect.gen(function* () { + const antigravityLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "antigravity" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(antigravityLaunch, { + command: "agy", + args: ["/tmp/workspace"], + }); + + const cursorLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "cursor" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(cursorLaunch, { + command: "cursor", + args: ["/tmp/workspace"], + }); + + const traeLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "trae" }, + "darwin", + ); + assert.deepEqual(traeLaunch, { + command: "trae", + args: ["/tmp/workspace"], + }); + + const kiroLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLaunch, { + command: "kiro", + args: ["ide", "/tmp/workspace"], + }); + + const vscodeLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscode" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(vscodeLaunch, { + command: "code", + args: ["/tmp/workspace"], + }); + + const vscodeInsidersLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLaunch, { + command: "code-insiders", + args: ["/tmp/workspace"], + }); + + const vscodiumLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLaunch, { + command: "codium", + args: ["/tmp/workspace"], + }); + + const zedLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "zed" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(zedLaunch, { + command: "zed", + args: ["/tmp/workspace"], + }); + + const ideaLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "idea" }, + "darwin", + ); + assert.deepEqual(ideaLaunch, { + command: "idea", + args: ["/tmp/workspace"], + }); + + const aquaLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "aqua" }, + "darwin", + ); + assert.deepEqual(aquaLaunch, { + command: "aqua", + args: ["/tmp/workspace"], + }); + + const clionLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "clion" }, + "darwin", + ); + assert.deepEqual(clionLaunch, { + command: "clion", + args: ["/tmp/workspace"], + }); + + const datagripLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "datagrip" }, + "darwin", + ); + assert.deepEqual(datagripLaunch, { + command: "datagrip", + args: ["/tmp/workspace"], + }); + + const dataspellLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "dataspell" }, + "darwin", + ); + assert.deepEqual(dataspellLaunch, { + command: "dataspell", + args: ["/tmp/workspace"], + }); + + const golandLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "goland" }, + "darwin", + ); + assert.deepEqual(golandLaunch, { + command: "goland", + args: ["/tmp/workspace"], + }); + + const phpstormLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "phpstorm" }, + "darwin", + ); + assert.deepEqual(phpstormLaunch, { + command: "phpstorm", + args: ["/tmp/workspace"], + }); + + const pycharmLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "pycharm" }, + "darwin", + ); + assert.deepEqual(pycharmLaunch, { + command: "pycharm", + args: ["/tmp/workspace"], + }); + + const riderLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "rider" }, + "darwin", + ); + assert.deepEqual(riderLaunch, { + command: "rider", + args: ["/tmp/workspace"], + }); + + const rubymineLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "rubymine" }, + "darwin", + ); + assert.deepEqual(rubymineLaunch, { + command: "rubymine", + args: ["/tmp/workspace"], + }); + + const rustroverLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "rustrover" }, + "darwin", + ); + assert.deepEqual(rustroverLaunch, { + command: "rustrover", + args: ["/tmp/workspace"], + }); + + const webstormLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "webstorm" }, + "darwin", + ); + assert.deepEqual(webstormLaunch, { + command: "webstorm", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("applies launch-style-specific navigation arguments", () => + Effect.gen(function* () { + const lineOnly = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(lineOnly, { + command: "cursor", + args: ["--goto", "/tmp/workspace/AGENTS.md:48"], + }); + + const lineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "cursor" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(lineAndColumn, { + command: "cursor", + args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], + }); + + const traeLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "trae" }, + "darwin", + ); + assert.deepEqual(traeLineAndColumn, { + command: "trae", + args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], + }); + + const kiroLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLineAndColumn, { + command: "kiro", + args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], + }); + + const vscodeLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(vscodeLineAndColumn, { + command: "code", + args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], + }); + + const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLineAndColumn, { + command: "code-insiders", + args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], + }); + + const vscodiumLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLineAndColumn, { + command: "codium", + args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], + }); + + const zedLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "zed" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(zedLineAndColumn, { + command: "zed", + args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"], + }); + + const zedLineOnly = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(zedLineOnly, { + command: "zed", + args: ["/tmp/workspace/AGENTS.md:48"], + }); + + const ideaLineOnly = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, + "darwin", + ); + assert.deepEqual(ideaLineOnly, { + command: "idea", + args: ["--line", "48", "/tmp/workspace/AGENTS.md"], + }); + + const ideaLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "idea" }, + "darwin", + ); + assert.deepEqual(ideaLineAndColumn, { + command: "idea", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const aquaLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "aqua" }, + "darwin", + ); + assert.deepEqual(aquaLineAndColumn, { + command: "aqua", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const clionLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "clion" }, + "darwin", + ); + assert.deepEqual(clionLineAndColumn, { + command: "clion", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const datagripLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "datagrip" }, + "darwin", + ); + assert.deepEqual(datagripLineAndColumn, { + command: "datagrip", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const dataspellLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "dataspell" }, + "darwin", + ); + assert.deepEqual(dataspellLineAndColumn, { + command: "dataspell", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const golandLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "goland" }, + "darwin", + ); + assert.deepEqual(golandLineAndColumn, { + command: "goland", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const phpstormLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "phpstorm" }, + "darwin", + ); + assert.deepEqual(phpstormLineAndColumn, { + command: "phpstorm", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const pycharmLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "pycharm" }, + "darwin", + ); + assert.deepEqual(pycharmLineAndColumn, { + command: "pycharm", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const riderLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rider" }, + "darwin", + ); + assert.deepEqual(riderLineAndColumn, { + command: "rider", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const rubymineLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rubymine" }, + "darwin", + ); + assert.deepEqual(rubymineLineAndColumn, { + command: "rubymine", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const rustroverLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rustrover" }, + "darwin", + ); + assert.deepEqual(rustroverLineAndColumn, { + command: "rustrover", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], + }); + + const webstormLineOnly = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" }, + "darwin", + ); + assert.deepEqual(webstormLineOnly, { + command: "webstorm", + args: ["--line", "48", "/tmp/workspace/AGENTS.md"], + }); + }), + ); + + it.effect("falls back to zeditor when zed is not installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); + + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { + PATH: dir, + }); + + assert.deepEqual(result, { + command: "zeditor", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("falls back to the primary command when no alias is installed", () => + Effect.gen(function* () { + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { + PATH: "", + }); + assert.deepEqual(result, { + command: "zed", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("maps file-manager editor to OS open commands", () => + Effect.gen(function* () { + const launch1 = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "file-manager" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(launch1, { + command: "open", + args: ["/tmp/workspace"], + }); + + const launch2 = yield* resolveEditorLaunch( + { cwd: "C:\\workspace", editor: "file-manager" }, + "win32", + { PATH: "" }, + ); + assert.deepEqual(launch2, { + command: "explorer", + args: ["C:\\workspace"], + }); + + const launch3 = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "file-manager" }, + "linux", + { PATH: "" }, + ); + assert.deepEqual(launch3, { + command: "xdg-open", + args: ["/tmp/workspace"], + }); + }), + ); +}); + +it("resolveBrowserLaunch maps default browser launchers by platform", () => { + const target = "https://example.com/some path?name=o'hara"; + + assert.deepEqual(resolveBrowserLaunch(target, "darwin").command, "open"); + assert.deepEqual(resolveBrowserLaunch(target, "darwin").args, [target]); + assert.deepEqual(resolveBrowserLaunch(target, "darwin").options, { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + + assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).command, "xdg-open"); + assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).args, [target]); + + const windows = resolveBrowserLaunch(target, "win32", { + SYSTEMROOT: "C:\\Windows", + }); + assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + assert.deepEqual(windows.args, [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodeUtf16LeBase64( + "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'", + ), + ]); + assert.deepEqual(windows.options, { + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); +}); + +it("resolveBrowserLaunch opens through Windows from WSL when not remote", () => { + const launch = resolveBrowserLaunch("https://example.com", "linux", { + WSL_DISTRO_NAME: "Ubuntu", + }); + assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); + assert.equal(launch.options.detached, true); +}); + +it("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => { + const launch = resolveBrowserLaunch("https://example.com", "linux", { + WSL_DISTRO_NAME: "Ubuntu", + SSH_CONNECTION: "client server", + }); + assert.equal(launch.command, "xdg-open"); +}); + +it.layer(NodeServices.layer)("launchBrowser", (it) => { + it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.StandardCommand | undefined; + let didUnref = false; + + const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { + spawn: (command) => + Effect.sync(() => { + assert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected a standard command"); + } + spawnedCommand = command; + return makeMockDetachedHandle(() => { + didUnref = true; + }); + }), + }); + + const result = yield* launchBrowser("https://example.com").pipe( + Effect.provide(spawnerLayer), + Effect.result, + ); + + assertSuccess(result, undefined); + assert.ok(spawnedCommand); + const expectedLaunch = resolveBrowserLaunch("https://example.com"); + assert.equal(spawnedCommand.command, expectedLaunch.command); + assert.deepEqual(spawnedCommand.args, expectedLaunch.args); + assert.deepEqual(spawnedCommand.options, expectedLaunch.options); + assert.equal(didUnref, true); + }), + ); +}); + +it.layer(NodeServices.layer)("launchEditorProcess", (it) => { + it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.StandardCommand | undefined; + let didUnref = false; + const expectedArgs = ["-e", "process.exit(0)"]; + + const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { + spawn: (command) => + Effect.sync(() => { + assert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected a standard command"); + } + spawnedCommand = command; + return makeMockDetachedHandle(() => { + didUnref = true; + }); + }), + }); + + const result = yield* launchEditorProcess({ + command: process.execPath, + args: expectedArgs, + }).pipe(Effect.provide(spawnerLayer), Effect.result); + + assertSuccess(result, undefined); + assert.ok(spawnedCommand); + assert.equal(spawnedCommand.command, process.execPath); + assert.deepEqual( + spawnedCommand.args, + process.platform === "win32" ? expectedArgs.map((arg) => `"${arg}"`) : expectedArgs, + ); + assert.deepEqual(spawnedCommand.options, { + detached: true, + shell: process.platform === "win32", + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + assert.equal(didUnref, true); + }), + ); + + it.effect("rejects when command does not exist", () => + Effect.gen(function* () { + const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}); + const result = yield* launchEditorProcess({ + command: `t3code-no-such-command-${yield* Random.nextUUIDv4}`, + args: [], + }).pipe(Effect.provide(spawnerLayer), Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); +}); + +it.layer(NodeServices.layer)("isCommandAvailable", (it) => { + it.effect("resolves win32 commands with PATHEXT", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); + yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); + const env = { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); + }), + ); + + it("returns false when a command is not on PATH", () => { + const env = { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); + }); + + it.effect("does not treat bare files without executable extension as available on win32", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); + yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); + const env = { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); + }), + ); + + it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); + yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); + const env = { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); + }), + ); + + it.effect("uses platform-specific PATH delimiter for platform overrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); + const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); + yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); + const env = { + PATH: `${firstDir};${secondDir}`, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); + }), + ); +}); + +it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { + it.effect("returns installed editors for command launches", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + + yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); + const editors = resolveAvailableEditors("win32", { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); + assert.deepEqual(editors, [ + "trae", + "kiro", + "vscode-insiders", + "vscodium", + "aqua", + "clion", + "datagrip", + "dataspell", + "goland", + "phpstorm", + "pycharm", + "rider", + "rubymine", + "rustrover", + "webstorm", + "file-manager", + ]); + }), + ); + + it.effect("includes zed when only the zeditor command is installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); + yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); + yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); + + const editors = resolveAvailableEditors("linux", { + PATH: dir, + }); + assert.deepEqual(editors, ["zed", "file-manager"]); + }), + ); + + it("omits file-manager when the platform opener is unavailable", () => { + const editors = resolveAvailableEditors("linux", { + PATH: "", + }); + assert.deepEqual(editors, []); + }); +}); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts new file mode 100644 index 00000000000..cbfee1d7054 --- /dev/null +++ b/apps/server/src/process/externalLauncher.ts @@ -0,0 +1,364 @@ +/** + * ExternalLauncher - external application launch service interface. + * + * Owns process launch helpers for browser URLs and workspace paths + * in configured editor integrations. + * + * @module ExternalLauncher + */ +import { + EDITORS, + ExternalLauncherError, + type EditorId, + type LaunchEditorInput, +} from "@t3tools/contracts"; +import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +// ============================== +// Definitions +// ============================== + +export { ExternalLauncherError }; +export type { LaunchEditorInput }; +export { isCommandAvailable } from "@t3tools/shared/shell"; + +interface EditorLaunch { + readonly command: string; + readonly args: ReadonlyArray; +} + +interface ProcessLaunch { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: ChildProcess.CommandOptions; +} + +interface TargetPathAndPosition { + readonly path: string; + readonly line: string; + readonly column: Option.Option; +} + +const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; +const POWERSHELL_ARGUMENTS_PREFIX = [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", +] as const; + +const DETACHED_IGNORE_STDIO_OPTIONS = { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", +} as const satisfies ChildProcess.CommandOptions; + +function parseTargetPathAndPosition(target: string): Option.Option { + const match = TARGET_WITH_POSITION_PATTERN.exec(target); + if (!match?.[1] || !match[2]) { + return Option.none(); + } + + return Option.some({ + path: match[1], + line: match[2], + column: Option.fromUndefinedOr(match[3]), + }); +} + +function resolveCommandEditorArgs( + editor: (typeof EDITORS)[number], + target: string, +): ReadonlyArray { + const parsedTarget = parseTargetPathAndPosition(target); + + switch (editor.launchStyle) { + case "direct-path": + return [target]; + case "goto": + return Option.isSome(parsedTarget) ? ["--goto", target] : [target]; + case "line-column": + return Option.match(parsedTarget, { + onNone: () => [target], + onSome: ({ path, line, column }) => [ + "--line", + line, + ...Option.match(column, { + onNone: () => [], + onSome: (value) => ["--column", value], + }), + path, + ], + }); + } +} + +function resolveEditorArgs( + editor: (typeof EDITORS)[number], + target: string, +): ReadonlyArray { + const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; + return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; +} + +function resolveAvailableCommand( + commands: ReadonlyArray, + options: CommandAvailabilityOptions = {}, +): Option.Option { + for (const command of commands) { + if (isCommandAvailable(command, options)) { + return Option.some(command); + } + } + return Option.none(); +} + +function encodeUtf16LeBase64(input: string): string { + const bytes = new Uint8Array(input.length * 2); + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + bytes[index * 2] = code & 0xff; + bytes[index * 2 + 1] = code >>> 8; + } + return Encoding.encodeBase64(bytes); +} + +function escapePowerShellStringLiteral(input: string): string { + return `'${input.replaceAll("'", "''")}'`; +} + +function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string { + return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; +} + +function resolveWslPowerShellPath(): string { + return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; +} + +function shouldUseWindowsBrowserFromWsl( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + platform === "linux" && + (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) && + env.SSH_CONNECTION === undefined && + env.SSH_TTY === undefined && + env.container === undefined + ); +} + +function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLaunch { + const encodedCommand = encodeUtf16LeBase64( + `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`, + ); + return { + command, + args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand], + options: { + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + }; +} + +function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { + switch (platform) { + case "darwin": + return "open"; + case "win32": + return "explorer"; + default: + return "xdg-open"; + } +} + +export function resolveBrowserLaunch( + target: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): ProcessLaunch { + if (platform === "darwin") { + return { + command: "open", + args: [target], + options: DETACHED_IGNORE_STDIO_OPTIONS, + }; + } + + if (platform === "win32") { + return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); + } + + if (shouldUseWindowsBrowserFromWsl(platform, env)) { + return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); + } + + return { + command: "xdg-open", + args: [target], + options: DETACHED_IGNORE_STDIO_OPTIONS, + }; +} + +export function resolveAvailableEditors( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const available: EditorId[] = []; + + for (const editor of EDITORS) { + if (editor.commands === null) { + const command = fileManagerCommandForPlatform(platform); + if (isCommandAvailable(command, { platform, env })) { + available.push(editor.id); + } + continue; + } + + const command = resolveAvailableCommand(editor.commands, { platform, env }); + if (Option.isSome(command)) { + available.push(editor.id); + } + } + + return available; +} + +/** + * ExternalLauncherShape - Service API for browser and editor launch actions. + */ +export interface ExternalLauncherShape { + /** + * Launch a URL target in the default browser. + */ + readonly launchBrowser: (target: string) => Effect.Effect; + + /** + * Launch a workspace path in a selected editor integration. + * + * Launches the editor as a detached process so server startup is not blocked. + */ + readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; +} + +/** + * ExternalLauncher - Service tag for browser/editor launch operations. + */ +export class ExternalLauncher extends Context.Service()( + "t3/process/ExternalLauncher", +) {} + +// ============================== +// Implementations +// ============================== + +export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( + input: LaunchEditorInput, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + yield* Effect.annotateCurrentSpan({ + "externalLauncher.editor": input.editor, + "externalLauncher.cwd": input.cwd, + "externalLauncher.platform": platform, + }); + const editorDef = EDITORS.find((editor) => editor.id === input.editor); + if (!editorDef) { + return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); + } + + if (editorDef.commands) { + const command = Option.getOrElse( + resolveAvailableCommand(editorDef.commands, { platform, env }), + () => editorDef.commands[0], + ); + return { + command, + args: resolveEditorArgs(editorDef, input.cwd), + }; + } + + if (editorDef.id !== "file-manager") { + return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); + } + + return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; +}); + +const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( + launch: ProcessLaunch, + errorMessage: string, +): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make(launch.command, launch.args, launch.options); + + yield* spawner.spawn(command).pipe( + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })), + ); +}); + +export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( + target: string, +): Effect.fn.Return { + return yield* launchAndUnref(resolveBrowserLaunch(target), "Browser auto-open failed"); +}); + +export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( + launch: EditorLaunch, +): Effect.fn.Return { + if (!isCommandAvailable(launch.command)) { + return yield* new ExternalLauncherError({ + message: `Editor command not found: ${launch.command}`, + }); + } + + const isWin32 = process.platform === "win32"; + yield* launchAndUnref( + { + command: launch.command, + args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], + options: { + detached: true, + shell: isWin32, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + }, + "failed to spawn detached process", + ); +}); + +const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + return { + launchBrowser: (target) => + launchBrowser(target).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + launchEditor: (input) => + Effect.flatMap(resolveEditorLaunch(input), (launch) => + launchEditorProcess(launch).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + } satisfies ExternalLauncherShape; +}); + +export const layer = Layer.effect(ExternalLauncher, make); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index dd909116d4d..fae9ad574cf 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,23 +1,298 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +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 Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { runProcess } from "./processRunner"; +import { + isWindowsCommandNotFound, + ProcessOutputLimitError, + ProcessRunner, + ProcessTimeoutError, + layer as ProcessRunnerLive, + type ProcessRunInput, +} from "./processRunner.ts"; + +type ChildProcessCommand = { + readonly command: string; + readonly args: ReadonlyArray; +}; + +// Accesses private properties of ChildProcessCommand for testing purposes +function asChildProcessCommand(command: unknown): ChildProcessCommand { + return command as ChildProcessCommand; +} + +function makeHandle(input: { + readonly stdout?: string | Stream.Stream; + readonly stderr?: string | Stream.Stream; + readonly code?: number; + readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"]; + readonly exitCode?: Effect.Effect; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: input.stdin ?? Sink.drain, + stdout: + typeof input.stdout === "string" + ? Stream.encodeText(Stream.make(input.stdout)) + : (input.stdout ?? Stream.empty), + stderr: + typeof input.stderr === "string" + ? Stream.encodeText(Stream.make(input.stderr)) + : (input.stderr ?? Stream.empty), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function makeSpawner( + f: (command: ChildProcessCommand) => Effect.Effect, +) { + return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command))); +} + +const runWith = + (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => (input: ProcessRunInput) => + Effect.service(ProcessRunner).pipe( + Effect.flatMap((runner) => + runner.run({ + ...input, + shell: input.shell ?? false, + }), + ), + Effect.provide( + ProcessRunnerLive.pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ), + ), + ); describe("runProcess", () => { - it("fails when output exceeds max buffer in default mode", async () => { - await expect( - runProcess("node", ["-e", "process.stdout.write('x'.repeat(2048))"], { maxBufferBytes: 128 }), - ).rejects.toThrow("exceeded stdout buffer limit"); + it.effect("collects stdout through an injected ChildProcessSpawner", () => + Effect.gen(function* () { + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.command).toBe("fake"); + expect(command.args).toEqual(["stdout-bytes", "32"]); + return makeHandle({ stdout: "x".repeat(32) }); + }), + ); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["stdout-bytes", "32"], + }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe("x".repeat(32)); + expect(result.timedOut).toBe(false); + }), + ); + + it.effect("runs through the ProcessRunner service", () => { + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.command).toBe("fake"); + expect(command.args).toEqual(["--service"]); + return makeHandle({ stdout: "service ok" }); + }), + ); + const layer = ProcessRunnerLive.pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + return Effect.gen(function* () { + const runner = yield* ProcessRunner; + const result = yield* runner.run({ + command: "fake", + args: ["--service"], + }); + + expect(result.stdout).toBe("service ok"); + }).pipe(Effect.provide(layer)); }); - it("truncates output when outputMode is truncate", async () => { - const result = await runProcess("node", ["-e", "process.stdout.write('x'.repeat(2048))"], { - maxBufferBytes: 128, - outputMode: "truncate", - }); + it.effect("fails when output exceeds max buffer in default mode", () => + Effect.gen(function* () { + const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); + + const error = yield* runWith(spawner)({ + command: "fake", + args: ["stdout-bytes", "2048"], + maxOutputBytes: 128, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProcessOutputLimitError); + }), + ); + + it.effect("fails fast on output limit before timeout for long-running output", () => + Effect.gen(function* () { + const textChunk = "x".repeat(64); + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + stdout: Stream.fromIterable(Array.from({ length: 10 }, () => textChunk)).pipe( + Stream.encodeText, + ), + exitCode: Effect.never, + }), + ), + ); + + const error = yield* runWith(spawner)({ + command: "fake", + args: ["spam-stdout"], + maxOutputBytes: 128, + timeout: "2 seconds", + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ProcessOutputLimitError); + }), + ); + + it.effect("truncates output when outputMode is truncate", () => + Effect.gen(function* () { + const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["stdout-bytes", "2048"], + maxOutputBytes: 128, + outputMode: "truncate", + }); + + expect(result.code).toBe(0); + expect(result.stdout.length).toBeLessThanOrEqual(128); + expect(result.stdoutTruncated).toBe(true); + expect(result.stderrTruncated).toBe(false); + }), + ); + + it.effect("writes stdin before waiting for exit", () => + Effect.gen(function* () { + const stdinWritten = yield* Deferred.make(); + const decoder = new TextDecoder(); + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + stdout: "stdin payload", + stdin: Sink.forEach((chunk: Uint8Array) => { + const text = decoder.decode(chunk, { stream: true }); + return text.includes("stdin payload") + ? Deferred.succeed(stdinWritten, undefined) + : Effect.void; + }), + exitCode: Deferred.await(stdinWritten).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["stdin-echo"], + stdin: "stdin payload", + }); + + expect(result.stdout).toBe("stdin payload"); + expect(result.code).toBe(0); + }), + ); + + it.effect("returns output for non-zero exit codes", () => + Effect.gen(function* () { + const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stderr: "boom", code: 2 }))); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["stderr-exit", "boom", "2"], + }); + + expect(result.code).toBe(2); + expect(result.stderr).toBe("boom"); + }), + ); + + it.effect("fails on timeout", () => + Effect.gen(function* () { + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + exitCode: Effect.never, + }), + ), + ); + const errorFiber = yield* runWith(spawner)({ + command: "fake", + args: ["sleep"], + timeout: "50 millis", + }).pipe(Effect.flip, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(50)); + const error = yield* Fiber.join(errorFiber); + + expect(error).toBeInstanceOf(ProcessTimeoutError); + }), + ); + + it.effect("returns a synthetic timed out result when timeoutBehavior is timedOutResult", () => + Effect.gen(function* () { + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + exitCode: Effect.never, + }), + ), + ); + const resultFiber = yield* runWith(spawner)({ + command: "fake", + args: ["sleep"], + timeout: "50 millis", + timeoutBehavior: "timedOutResult", + }).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(50)); + const result = yield* Fiber.join(resultFiber); + + expect(result).toMatchObject({ + stdout: "", + stderr: "", + code: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + }); + }), + ); +}); + +describe("isWindowsCommandNotFound", () => { + it("matches the localized German cmd.exe error text", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - expect(result.code).toBe(0); - expect(result.stdout.length).toBeLessThanOrEqual(128); - expect(result.stdoutTruncated).toBe(true); - expect(result.stderrTruncated).toBe(false); + try { + expect( + isWindowsCommandNotFound( + 1, + "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", + ), + ).toBe(true); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } }); }); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 5402612887d..55988a8533a 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -1,270 +1,336 @@ -import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process"; +import * as Data from "effect/Data"; +import * as Context from "effect/Context"; +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 PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + collectUint8StreamText, + type CollectedUint8StreamText, +} from "./stream/collectUint8StreamText.ts"; -export interface ProcessRunOptions { - cwd?: string | undefined; - timeoutMs?: number | undefined; - env?: NodeJS.ProcessEnv | undefined; - stdin?: string | undefined; - allowNonZeroExit?: boolean | undefined; - maxBufferBytes?: number | undefined; - outputMode?: "error" | "truncate" | undefined; +export interface ProcessRunInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly spawnCwd?: string | undefined; + readonly timeout?: Duration.Input | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; + readonly stdin?: string | undefined; + readonly maxOutputBytes?: number | undefined; + readonly outputMode?: "error" | "truncate" | undefined; + readonly truncatedMarker?: string | undefined; + readonly shell?: boolean | string | undefined; + /** + * On timeout, return a synthetic timedOut result. + * Partial stdout/stderr are not preserved. + */ + readonly timeoutBehavior?: "error" | "timedOutResult" | undefined; } -export interface ProcessRunResult { - stdout: string; - stderr: string; - code: number | null; - signal: NodeJS.Signals | null; - timedOut: boolean; - stdoutTruncated?: boolean | undefined; - stderrTruncated?: boolean | undefined; +export interface ProcessRunOutput { + readonly stdout: string; + readonly stderr: string; + readonly code: ChildProcessSpawner.ExitCode | null; + readonly timedOut: boolean; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; } -function commandLabel(command: string, args: readonly string[]): string { - return [command, ...args].join(" "); -} +export class ProcessSpawnError extends Data.TaggedError("ProcessSpawnError")<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly cause: unknown; +}> {} -function normalizeSpawnError(command: string, args: readonly string[], error: unknown): Error { - if (!(error instanceof Error)) { - return new Error(`Failed to run ${commandLabel(command, args)}.`); - } +export class ProcessStdinError extends Data.TaggedError("ProcessStdinError")<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly cause: unknown; +}> {} - const maybeCode = (error as NodeJS.ErrnoException).code; - if (maybeCode === "ENOENT") { - return new Error(`Command not found: ${command}`); - } +export class ProcessOutputLimitError extends Data.TaggedError("ProcessOutputLimitError")<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly stream: "stdout" | "stderr"; + readonly maxBytes: number; +}> {} - return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); -} +export class ProcessReadError extends Data.TaggedError("ProcessReadError")<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly stream: "stdout" | "stderr" | "exitCode"; + readonly cause: unknown; +}> {} -export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { - if (process.platform !== "win32") return false; - if (code === 9009) return true; - return /is not recognized as an internal or external command/i.test(stderr); -} +export class ProcessTimeoutError extends Data.TaggedError("ProcessTimeoutError")<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly timeoutMs: number; +}> {} -function normalizeExitError( - command: string, - args: readonly string[], - result: ProcessRunResult, -): Error { - if (isWindowsCommandNotFound(result.code, result.stderr)) { - return new Error(`Command not found: ${command}`); - } +export type ProcessRunError = + | ProcessSpawnError + | ProcessStdinError + | ProcessOutputLimitError + | ProcessReadError + | ProcessTimeoutError; - const reason = result.timedOut - ? "timed out" - : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`; - const stderr = result.stderr.trim(); - const detail = stderr.length > 0 ? ` ${stderr}` : ""; - return new Error(`${commandLabel(command, args)} ${reason}.${detail}`); +export interface ProcessRunnerShape { + readonly run: (input: ProcessRunInput) => Effect.Effect; } -function normalizeStdinError(command: string, args: readonly string[], error: unknown): Error { - if (!(error instanceof Error)) { - return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`); - } - return new Error(`Failed to write stdin for ${commandLabel(command, args)}: ${error.message}`); -} +export class ProcessRunner extends Context.Service()( + "t3/process/ProcessRunner", +) {} -function normalizeBufferError( - command: string, - args: readonly string[], - stream: "stdout" | "stderr", - maxBufferBytes: number, -): Error { - return new Error( - `${commandLabel(command, args)} exceeded ${stream} buffer limit (${maxBufferBytes} bytes).`, - ); -} +const DEFAULT_TIMEOUT = "60 seconds"; +const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; +const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ + /is not recognized as an internal or external command/i, + /n.o . reconhecido como um comando interno/i, + /non . riconosciuto come comando interno o esterno/i, + /n.est pas reconnu en tant que commande interne/i, + /no se reconoce como un comando interno o externo/i, + /wird nicht als interner oder externer befehl/i, +] as const; -/** - * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` - * wrapper, leaving the actual command running. Use `taskkill /T` to kill the - * entire process tree instead. - */ -function killChild(child: ChildProcessHandle, signal: NodeJS.Signals = "SIGTERM"): void { - if (process.platform === "win32" && child.pid !== undefined) { - try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); - return; - } catch { - // fallback to direct kill - } - } - child.kill(signal); +function hasWindowsCommandNotFoundMessage(output: string): boolean { + return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); } -function appendChunkWithinLimit( - target: string, - currentBytes: number, - chunk: Buffer, - maxBytes: number, -): { - next: string; - nextBytes: number; - truncated: boolean; -} { - const remaining = maxBytes - currentBytes; - if (remaining <= 0) { - return { next: target, nextBytes: currentBytes, truncated: true }; - } - if (chunk.length <= remaining) { - return { - next: `${target}${chunk.toString()}`, - nextBytes: currentBytes + chunk.length, - truncated: false, - }; - } - return { - next: `${target}${chunk.subarray(0, remaining).toString()}`, - nextBytes: currentBytes + remaining, - truncated: true, - }; +export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { + if (process.platform !== "win32") return false; + if (code === 9009) return true; + return hasWindowsCommandNotFoundMessage(stderr); } -export async function runProcess( - command: string, - args: readonly string[], - options: ProcessRunOptions = {}, -): Promise { - const timeoutMs = options.timeoutMs ?? 60_000; - const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; - const outputMode = options.outputMode ?? "error"; +const collectText = Effect.fn("processRunner.collectText")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly streamName: "stdout" | "stderr"; + readonly stream: Stream.Stream; + readonly maxOutputBytes: number; + readonly outputMode: "error" | "truncate"; + readonly truncatedMarker: string; +}) { + const stream = input.stream.pipe( + Stream.mapError( + (cause) => + new ProcessReadError({ + command: input.command, + args: input.args, + cwd: input.cwd, + stream: input.streamName, + cause, + }), + ), + ); - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env, - stdio: "pipe", - shell: process.platform === "win32", + if (input.outputMode === "truncate") { + return yield* collectUint8StreamText({ + stream, + maxBytes: input.maxOutputBytes, + truncatedMarker: input.truncatedMarker, }); + } - let stdout = ""; - let stderr = ""; - let stdoutBytes = 0; - let stderrBytes = 0; - let stdoutTruncated = false; - let stderrTruncated = false; - let timedOut = false; - let settled = false; - let forceKillTimer: ReturnType | null = null; + return yield* stream.pipe( + Stream.runFoldEffect< + { + readonly chunks: Uint8Array[]; + readonly bytes: number; + }, + Uint8Array, + ProcessOutputLimitError | ProcessReadError, + never + >( + () => ({ chunks: [], bytes: 0 }), + (state, chunk) => { + const remainingBytes = input.maxOutputBytes - state.bytes; + if (remainingBytes <= 0 || chunk.byteLength > remainingBytes) { + return Effect.fail( + new ProcessOutputLimitError({ + command: input.command, + args: input.args, + cwd: input.cwd, + stream: input.streamName, + maxBytes: input.maxOutputBytes, + }), + ); + } - const timeoutTimer = setTimeout(() => { - timedOut = true; - killChild(child, "SIGTERM"); - forceKillTimer = setTimeout(() => { - killChild(child, "SIGKILL"); - }, 1_000); - }, timeoutMs); + state.chunks.push(chunk); + return Effect.succeed({ + chunks: state.chunks, + bytes: state.bytes + chunk.byteLength, + }); + }, + ), + Effect.map( + (state): CollectedUint8StreamText => ({ + text: Buffer.concat(state.chunks, state.bytes).toString("utf8"), + bytes: state.bytes, + truncated: false, + }), + ), + ); +}); - const finalize = (callback: () => void): void => { - if (settled) return; - settled = true; - clearTimeout(timeoutTimer); - if (forceKillTimer) { - clearTimeout(forceKillTimer); +function finalizeRunProcess( + effect: Effect.Effect, + input: ProcessRunInput, +): Effect.Effect> { + const timeout = Duration.fromInputUnsafe(input.timeout ?? DEFAULT_TIMEOUT); + const timeoutBehavior = input.timeoutBehavior ?? "error"; + + return effect.pipe( + Effect.scoped, + Effect.timeoutOption(timeout), + Effect.flatMap((result) => { + if (Option.isSome(result)) { + return Effect.succeed(result.value); + } + if (timeoutBehavior === "timedOutResult") { + return Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ProcessRunOutput); } - callback(); - }; + return Effect.fail( + new ProcessTimeoutError({ + command: input.command, + args: input.args, + cwd: input.cwd, + timeoutMs: Duration.toMillis(timeout), + }), + ); + }), + ); +} - const fail = (error: Error): void => { - killChild(child, "SIGTERM"); - finalize(() => { - reject(error); - }); - }; +const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + input: ProcessRunInput, +): Effect.fn.Return { + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const outputMode = input.outputMode ?? "error"; + const truncatedMarker = input.truncatedMarker ?? ""; - const appendOutput = (stream: "stdout" | "stderr", chunk: Buffer | string): Error | null => { - const chunkBuffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; - const text = chunkBuffer.toString(); - const byteLength = chunkBuffer.length; - if (stream === "stdout") { - if (outputMode === "truncate") { - const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunkBuffer, maxBufferBytes); - stdout = appended.next; - stdoutBytes = appended.nextBytes; - stdoutTruncated = stdoutTruncated || appended.truncated; - return null; - } - stdout += text; - stdoutBytes += byteLength; - if (stdoutBytes > maxBufferBytes) { - return normalizeBufferError(command, args, "stdout", maxBufferBytes); - } - } else { - if (outputMode === "truncate") { - const appended = appendChunkWithinLimit(stderr, stderrBytes, chunkBuffer, maxBufferBytes); - stderr = appended.next; - stderrBytes = appended.nextBytes; - stderrTruncated = stderrTruncated || appended.truncated; - return null; - } - stderr += text; - stderrBytes += byteLength; - if (stderrBytes > maxBufferBytes) { - return normalizeBufferError(command, args, "stderr", maxBufferBytes); - } - } - return null; - }; + const child = yield* spawner + .spawn( + ChildProcess.make(input.command, [...input.args], { + ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), + ...(input.env !== undefined + ? { + env: input.env, + extendEnv: true, + } + : {}), + ...(input.shell !== undefined ? { shell: input.shell } : {}), + }), + ) + .pipe( + Effect.mapError( + (cause) => + new ProcessSpawnError({ + command: input.command, + args: input.args, + cwd: input.cwd, + cause, + }), + ), + ); - child.stdout.on("data", (chunk: Buffer | string) => { - const error = appendOutput("stdout", chunk); - if (error) { - fail(error); - } - }); + const writeStdin = + input.stdin === undefined + ? Effect.void + : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + Effect.mapError( + (cause) => + new ProcessStdinError({ + command: input.command, + args: input.args, + cwd: input.cwd, + cause, + }), + ), + ); - child.stderr.on("data", (chunk: Buffer | string) => { - const error = appendOutput("stderr", chunk); - if (error) { - fail(error); - } - }); + const [stdout, stderr] = yield* Effect.all( + [ + collectText({ + command: input.command, + args: input.args, + cwd: input.cwd, + streamName: "stdout", + stream: child.stdout, + maxOutputBytes, + outputMode, + truncatedMarker, + }), + collectText({ + command: input.command, + args: input.args, + cwd: input.cwd, + streamName: "stderr", + stream: child.stderr, + maxOutputBytes, + outputMode, + truncatedMarker, + }), + writeStdin, + ], + { concurrency: "unbounded" }, + ); - child.once("error", (error) => { - finalize(() => { - reject(normalizeSpawnError(command, args, error)); - }); - }); + const exitCode = yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new ProcessReadError({ + command: input.command, + args: input.args, + cwd: input.cwd, + stream: "exitCode", + cause, + }), + ), + ); - child.once("close", (code, signal) => { - const result: ProcessRunResult = { - stdout, - stderr, - code, - signal, - timedOut, - stdoutTruncated, - stderrTruncated, - }; + return { + stdout: stdout.text, + stderr: stderr.text, + code: exitCode, + timedOut: false, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies ProcessRunOutput; +}); - finalize(() => { - if (!options.allowNonZeroExit && (timedOut || (code !== null && code !== 0))) { - reject(normalizeExitError(command, args, result)); - return; - } - resolve(result); - }); - }); +export const make = Effect.fn("makeProcessRunner")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - child.stdin.once("error", (error) => { - fail(normalizeStdinError(command, args, error)); - }); + const run: ProcessRunnerShape["run"] = (input) => + finalizeRunProcess(runProcessCore(spawner, input), input); - if (options.stdin !== undefined) { - child.stdin.write(options.stdin, (error) => { - if (error) { - fail(normalizeStdinError(command, args, error)); - return; - } - child.stdin.end(); - }); - return; - } - child.stdin.end(); + return ProcessRunner.of({ + run, }); -} +}); + +export const layer = Layer.effect(ProcessRunner, make()); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index 29b30739b9c..c983aca4ba7 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index 3004a7a45cf..ed5412bf138 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -1,4 +1,7 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { ProjectFaviconResolver, @@ -27,6 +30,7 @@ const FAVICON_CANDIDATES = [ "assets/icon.png", "assets/logo.svg", "assets/logo.png", + ".idea/icon.svg", ] as const; // Files that may contain a or icon metadata declaration. diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 6366a768b75..d0fafd56a01 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -1,53 +1,56 @@ -import { Effect, Layer, Stream } from "effect"; +import { ProjectId, type OrchestrationProject } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vitest"; -import type { OrchestrationReadModel } from "@t3tools/contracts"; -import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; -const emptySnapshot = ( - scripts: OrchestrationReadModel["projects"][number]["scripts"], -): OrchestrationReadModel => - ({ - snapshotSequence: 1, - updatedAt: "2026-01-01T00:00:00.000Z", - projects: [ - { - id: "project-1", - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: null, - scripts, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: null, - }, - ], - threads: [], - providerSessions: [], - providerStatuses: [], - pendingApprovals: [], - latestTurnByThreadId: {}, - }) as unknown as OrchestrationReadModel; +const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => + Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), + ), + getProjectShellById: (projectId) => + Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }); describe("ProjectSetupScriptRunner", () => { it("returns no-script when no setup script exists", async () => { const open = vi.fn(); const write = vi.fn(); + const project = makeProject([]); const runner = await Effect.runPromise( Effect.service(ProjectSetupScriptRunner).pipe( Effect.provide( ProjectSetupScriptRunnerLive.pipe( - Layer.provideMerge( - Layer.succeed(OrchestrationEngineService, { - getReadModel: () => Effect.succeed(emptySnapshot([])), - readEvents: () => Stream.empty, - dispatch: () => Effect.die(new Error("unused")), - streamDomainEvents: Stream.empty, - }), - ), + Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( Layer.succeed(TerminalManager, { open, @@ -93,29 +96,20 @@ describe("ProjectSetupScriptRunner", () => { }), ); const write = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); const runner = await Effect.runPromise( Effect.service(ProjectSetupScriptRunner).pipe( Effect.provide( ProjectSetupScriptRunnerLive.pipe( - Layer.provideMerge( - Layer.succeed(OrchestrationEngineService, { - getReadModel: () => - Effect.succeed( - emptySnapshot([ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]), - ), - readEvents: () => Stream.empty, - dispatch: () => Effect.die(new Error("unused")), - streamDomainEvents: Stream.empty, - }), - ), + Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( Layer.succeed(TerminalManager, { open, diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 3bac8cf0abf..61cd043b43b 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -1,31 +1,40 @@ +import { ProjectId } from "@t3tools/contracts"; import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; -import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { type ProjectSetupScriptRunnerShape, ProjectSetupScriptRunner, + ProjectSetupScriptRunnerError, } from "../Services/ProjectSetupScriptRunner.ts"; const makeProjectSetupScriptRunner = Effect.gen(function* () { - const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager; const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => Effect.gen(function* () { - const readModel = yield* orchestrationEngine.getReadModel(); const project = (input.projectId - ? readModel.projects.find((entry) => entry.id === input.projectId) + ? yield* projectionSnapshotQuery + .getProjectShellById(ProjectId.make(input.projectId)) + .pipe(Effect.map(Option.getOrUndefined)) : null) ?? (input.projectCwd - ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd) + ? yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(input.projectCwd) + .pipe(Effect.map(Option.getOrUndefined)) : null) ?? null; if (!project) { - return yield* Effect.fail(new Error("Project was not found for setup script execution.")); + return yield* new ProjectSetupScriptRunnerError({ + message: "Project was not found for setup script execution.", + }); } const script = setupProjectScript(project.scripts); @@ -62,7 +71,26 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () { terminalId, cwd, } as const; - }); + }).pipe( + Effect.mapError((cause) => { + if ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "ProjectSetupScriptRunnerError" + ) { + return cause as ProjectSetupScriptRunnerError; + } + const message = + typeof cause === "object" && + cause !== null && + "message" in cause && + typeof cause.message === "string" + ? cause.message + : String(cause); + return new ProjectSetupScriptRunnerError({ message }); + }), + ); return { runForThread, diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 57f4464804d..a47181c8667 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,17 +1,31 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { Duration, Effect, FileSystem, Layer } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { TestClock } from "effect/testing"; -import { runProcess } from "../../processRunner.ts"; +import * as ProcessRunner from "../../processRunner.ts"; import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; import { makeRepositoryIdentityResolver, RepositoryIdentityResolverLive, } from "./RepositoryIdentityResolver.ts"; +const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); +const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); + const git = (cwd: string, args: ReadonlyArray) => - Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); + Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner.run({ + command: "git", + args: ["-C", cwd, ...args], + shell: process.platform === "win32", + }); + }).pipe(Effect.provide(ProcessRunner.layer)); const makeRepositoryIdentityResolverTestLayer = (options: { readonly positiveCacheTtl?: Duration.Input; @@ -23,7 +37,7 @@ const makeRepositoryIdentityResolverTestLayer = (options: { cacheCapacity: 16, ...options, }), - ); + ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => @@ -38,9 +52,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); + const resolvedIdentityRoot = + identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); + const resolvedCwd = yield* fileSystem.realPath(cwd); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd)); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); @@ -48,6 +66,33 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-root-test-", + }); + const nestedWorkspace = path.join(repoRoot, "packages", "web"); + + yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); + yield* git(repoRoot, ["init"]); + yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nestedWorkspace); + const resolvedIdentityRoot = + identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); + const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( + normalizeResolvedPath(resolvedRepoRoot), + ); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -112,7 +157,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ); it.effect( - "refreshes cached null identities after the negative TTL when a remote is configured later", + "keeps null identities cached across repeated resolves until the negative TTL expires", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -128,8 +173,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); + for (const _attempt of [1, 2, 3]) { + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + } yield* TestClock.adjust(Duration.millis(120)); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 531737ec66c..926c1d0c2ec 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -1,8 +1,15 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; -import { Cache, Duration, Effect, Exit, Layer } from "effect"; -import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { + detectSourceControlProviderFromGitRemoteUrl, + normalizeGitRemoteUrl, +} from "@t3tools/shared/git"; -import { runProcess } from "../../processRunner.ts"; +import * as ProcessRunner from "../../processRunner.ts"; import { RepositoryIdentityResolver, type RepositoryIdentityResolverShape, @@ -42,9 +49,10 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); + const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); const repositoryPath = canonicalKey.split("/").slice(1).join("/"); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; @@ -57,8 +65,9 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, + rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), - ...(hostingProvider ? { provider: hostingProvider.kind } : {}), + ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), ...(owner ? { owner } : {}), ...(repositoryName ? { name: repositoryName } : {}), }; @@ -66,7 +75,7 @@ function buildRepositoryIdentity(input: { const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; @@ -74,50 +83,63 @@ interface RepositoryIdentityResolverOptions { readonly negativeCacheTtl?: Duration.Input; } -async function resolveRepositoryIdentityCacheKey(cwd: string): Promise { +const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* ( + cwd: string, +) { + const processRunner = yield* ProcessRunner.ProcessRunner; let cacheKey = cwd; - try { - const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { - allowNonZeroExit: true, - }); - if (topLevelResult.code !== 0) { - return cacheKey; - } - - const candidate = topLevelResult.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - } catch { + const topLevelResult = yield* processRunner + .run({ + command: "git", + args: ["-C", cwd, "rev-parse", "--show-toplevel"], + timeoutBehavior: "timedOutResult", + shell: process.platform === "win32", + }) + .pipe(Effect.option); + if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { return cacheKey; } - return cacheKey; -} + const candidate = topLevelResult.value.stdout.trim(); + if (candidate.length > 0) { + cacheKey = candidate; + } -async function resolveRepositoryIdentityFromCacheKey( - cacheKey: string, -): Promise { - try { - const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { - allowNonZeroExit: true, - }); - if (remoteResult.code !== 0) { + return cacheKey; +}); + +const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")( + function* ( + cacheKey: string, + ): Effect.fn.Return { + const processRunner = yield* ProcessRunner.ProcessRunner; + const remoteResult = yield* processRunner + .run({ + command: "git", + args: ["-C", cacheKey, "remote", "-v"], + timeoutBehavior: "timedOutResult", + shell: process.platform === "win32", + }) + .pipe(Effect.option); + if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { return null; } - const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return remote ? buildRepositoryIdentity(remote) : null; - } catch { - return null; - } -} + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; + }, +); export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( function* (options: RepositoryIdentityResolverOptions = {}) { + const processRunner = yield* ProcessRunner.ProcessRunner; + const repositoryIdentityCache = yield* Cache.makeWith( - (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), + (cacheKey) => + resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), { capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, timeToLive: Exit.match({ @@ -133,7 +155,9 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { - const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); + const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ); return yield* Cache.get(repositoryIdentityCache, cacheKey); }); @@ -146,4 +170,4 @@ export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityR export const RepositoryIdentityResolverLive = Layer.effect( RepositoryIdentityResolver, makeRepositoryIdentityResolver(), -); +).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts index c05dfe8f1fd..ad1b466e2c7 100644 --- a/apps/server/src/project/Services/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Services/ProjectFaviconResolver.ts @@ -6,8 +6,8 @@ * * @module ProjectFaviconResolver */ -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; /** * ProjectFaviconResolverShape - Service API for project favicon lookup. diff --git a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts index acba335a6e7..184c75b5019 100644 --- a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts @@ -1,5 +1,6 @@ -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import type * as Effect from "effect/Effect"; export interface ProjectSetupScriptRunnerResultNoScript { readonly status: "no-script"; @@ -25,10 +26,16 @@ export interface ProjectSetupScriptRunnerInput { readonly preferredTerminalId?: string; } +export class ProjectSetupScriptRunnerError extends Data.TaggedError( + "ProjectSetupScriptRunnerError", +)<{ + readonly message: string; +}> {} + export interface ProjectSetupScriptRunnerShape { readonly runForThread: ( input: ProjectSetupScriptRunnerInput, - ) => Effect.Effect; + ) => Effect.Effect; } export class ProjectSetupScriptRunner extends Context.Service< diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts index d3eea846199..ef0b128c6f7 100644 --- a/apps/server/src/project/Services/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Services/RepositoryIdentityResolver.ts @@ -1,6 +1,6 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; export interface RepositoryIdentityResolverShape { readonly resolve: (cwd: string) => Effect.Effect; diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts new file mode 100644 index 00000000000..76055f8b8be --- /dev/null +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -0,0 +1,134 @@ +export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) + +You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. + +## Mode rules (strict) + +You are in **Plan Mode** until a developer message explicitly ends it. + +Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. + +## Plan Mode vs update_plan tool + +Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`\` block. + +Separately, \`update_plan\` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use \`update_plan\` in Plan mode, it will return an error. + +## Execution vs. mutation in Plan Mode + +You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. + +### Allowed (non-mutating, plan-improving) + +Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: + +* Reading or searching files, configs, schemas, types, manifests, and docs +* Static analysis, inspection, and repo exploration +* Dry-run style commands when they do not edit repo-tracked files +* Tests, builds, or checks that may write to caches or build artifacts (for example, \`target/\`, \`.cache/\`, or snapshots) so long as they do not edit repo-tracked files + +### Not allowed (mutating, plan-executing) + +Actions that implement the plan or change repo-tracked state. Examples: + +* Editing or writing files +* Running formatters or linters that rewrite files +* Applying patches, migrations, or codegen that updates repo-tracked files +* Side-effectful commands whose purpose is to carry out the plan rather than refine it + +When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. + +## PHASE 1 - Ground in the environment (explore first, ask second) + +Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. + +Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. + +Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. + +Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. + +## PHASE 2 - Intent chat (what they actually want) + +* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. +* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet-ask. + +## PHASE 3 - Implementation chat (what/how we'll build) + +* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. + +## Asking questions + +Critical rules: + +* Strongly prefer using the \`request_user_input\` tool to ask any questions. +* Offer only meaningful multiple-choice options; don't include filler choices that are obviously wrong or irrelevant. +* In rare cases where an unavoidable, important question can't be expressed with reasonable multiple-choice options (due to extreme ambiguity), you may ask it directly without the tool. + +You SHOULD ask many questions, but each question must: + +* materially change the spec/plan, OR +* confirm/lock an assumption, OR +* choose between meaningful tradeoffs. +* not be answerable by non-mutating commands. + +Use the \`request_user_input\` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. + +## Two kinds of unknowns (treat differently) + +1. **Discoverable facts** (repo/system truth): explore first. + + * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). + * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. + * If asking, present concrete candidates (paths/service names) + recommend one. + * Never ask questions you can answer from your environment (e.g., "where is this struct"). + +2. **Preferences/tradeoffs** (not discoverable): ask early. + + * These are intent or implementation preferences that cannot be derived from exploration. + * Provide 2-4 mutually exclusive options + a recommended default. + * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. + +## Finalization rule + +Only output the final plan when it is decision complete and leaves no decisions to the implementer. + +When you present the official plan, wrap it in a \`\` block so the client can render it specially: + +1) The opening tag must be on its own line. +2) Start the plan content on the next line (no text on the same line as the tag). +3) The closing tag must be on its own line. +4) Use Markdown inside the block. +5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. + +Example: + + +plan content + + +plan content should be human and agent digestible. The final plan must be plan-only and include: + +* A clear title +* A brief summary section +* Important changes or additions to public APIs/interfaces/types +* Test cases and scenarios +* Explicit assumptions and defaults chosen where needed + +Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. + +Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. +`; + +export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default + +You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. + +Your active mode changes only when new developer instructions with a different \`...\` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan. + +## request_user_input availability + +The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. + +In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. +`; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts new file mode 100644 index 00000000000..e3f15d865c9 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -0,0 +1,205 @@ +/** + * ClaudeDriver — `ProviderDriver` for the Claude Agent SDK runtime. + * + * Mirrors `CodexDriver`: a plain value whose `create()` returns one + * `ProviderInstance` bundling `snapshot` / `adapter` / `textGeneration` + * closures captured over the per-instance `ClaudeSettings`. + * + * Unlike Codex, the Claude snapshot probe may invoke a secondary probe + * (`probeClaudeCapabilities`) to read Anthropic account + slash-command + * metadata. That probe is per-instance and keyed by binary + resolved HOME so + * two concurrent Claude instances don't cross-contaminate account metadata. + * + * @module provider/Drivers/ClaudeDriver + */ +import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; +import { + checkClaudeProviderStatus, + makePendingClaudeProvider, + probeClaudeCapabilities, +} from "../Layers/ClaudeProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +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 { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.local/bin/claude") || + normalized.endsWith("/.local/bin/claude.exe") || + normalized.includes("/.local/share/claude/") + ); +} + +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@anthropic-ai/claude-code", + homebrewFormula: "claude-code", + nativeUpdate: { + executable: "claude", + args: ["update"], + lockKey: "claude-native", + isCommandPath: isClaudeNativeCommandPath, + }, +}); + +export type ClaudeDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const ClaudeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Claude", + supportsMultipleInstances: true, + }, + configSchema: ClaudeSettings, + defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey, + }); + + const adapterOptions = { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }; + const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); + const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); + + // Per-instance capabilities cache: keyed on binary + resolved HOME so + // account-specific probes never share auth metadata across instances. + const capabilitiesProbeCache = yield* Cache.make({ + capacity: 1, + timeToLive: CAPABILITIES_PROBE_TTL, + lookup: () => + probeClaudeCapabilities(effectiveConfig, processEnv).pipe( + Effect.provideService(Path.Path, path), + ), + }); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); + + const checkProvider = checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingClaudeProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity: { + ...fallbackContinuationIdentity, + continuationKey: continuationGroupKey, + }, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts new file mode 100644 index 00000000000..87298f80125 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -0,0 +1,53 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { + makeClaudeCapabilitiesCacheKey, + makeClaudeContinuationGroupKey, + makeClaudeEnvironment, + resolveClaudeHomePath, +} from "./ClaudeHome.ts"; + +it.layer(NodeServices.layer)("ClaudeHome", (it) => { + describe("Claude home resolution", () => { + it.effect("uses the process home when no Claude home override is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir()); + + expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); + expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); + }), + ); + + it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homePath = "~/.claude-work"; + const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); + + expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); + expect((yield* makeClaudeEnvironment({ homePath })).HOME).toBe(resolved); + expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); + expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( + `claude\0${resolved}`, + ); + }), + ); + + it.effect("keeps continuation compatible across instances with the same Claude HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir()); + + expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe( + `claude:home:${resolved}`, + ); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts new file mode 100644 index 00000000000..9a4d1ce9cdf --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -0,0 +1,44 @@ +import * as NodeOS from "node:os"; + +import type { ClaudeSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( + config: Pick, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); +}); + +export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( + config: Pick, + baseEnv: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const homePath = config.homePath.trim(); + if (homePath.length === 0) return baseEnv; + const resolvedHomePath = yield* resolveClaudeHomePath(config); + return { + ...baseEnv, + HOME: resolvedHomePath, + }; +}); + +export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( + function* (config: Pick): Effect.fn.Return { + const resolvedHomePath = yield* resolveClaudeHomePath(config); + return `claude:home:${resolvedHomePath}`; + }, +); + +export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( + function* ( + config: Pick, + ): Effect.fn.Return { + const resolvedHomePath = yield* resolveClaudeHomePath(config); + return `${config.binaryPath}\0${resolvedHomePath}`; + }, +); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts new file mode 100644 index 00000000000..48bc19e5612 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -0,0 +1,202 @@ +/** + * CodexDriver — first concrete `ProviderDriver` in the new per-instance model. + * + * A driver is a plain value (not a Context.Service) whose `create()` returns + * one `ProviderInstance` bundling: + * - `snapshot` — the live `ServerProviderShape` for this instance; + * - `adapter` — the Codex session/turn/approval runtime; + * - `textGeneration` — commit/PR/branch/title generation via `codex exec`. + * + * Each call to `create()` captures the `codexConfig` argument in closures + * owned by the returned instance. Two instances created with different + * `homePath`s (e.g. `codex_personal` + `codex_work`) therefore run with + * fully independent Codex app-server processes and `CODEX_HOME` + * environments — no shared mutable state. + * + * Resource lifecycle: `create()` runs in a scope handed in by the registry. + * Closing that scope releases the adapter's child processes, the managed + * snapshot's refresh fibre, and the text-generation binaries' transient + * scratch files. The registry uses this to tear down an instance when its + * `providerInstances` entry disappears or its config changes. + * + * @module provider/Drivers/CodexDriver + */ +import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; +import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + codexContinuationIdentity, + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./CodexHomeLayout.ts"; +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", + homebrewFormula: "codex", + nativeUpdate: null, +}); + +/** + * Services the driver needs to materialize an instance. Surfaced as the + * driver's `R` so the registry layer aggregates these across every + * registered driver and the runtime satisfies them once. + */ +export type CodexDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +/** + * Stamp instance identity onto a `ServerProvider` snapshot produced by the + * driver-kind-only codex helpers. Once `buildServerProvider` in + * `providerSnapshot.ts` is widened to accept `instanceId`/`driver`, this + * wrapper disappears. + */ +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CodexDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Codex", + supportsMultipleInstances: true, + }, + configSchema: CodexSettings, + defaultConfig: (): CodexSettings => decodeCodexSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const homeLayout = yield* resolveCodexHomeLayout(config); + const continuationIdentity = codexContinuationIdentity(homeLayout); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: cause.message, + cause, + }), + ), + ); + const effectiveConfig = { + ...config, + enabled, + homePath: homeLayout.effectiveHomePath ?? "", + } satisfies CodexSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error + // channels at construction time — their failure modes are all on the + // per-operation closures they return. No `mapError` wrapper is needed + // here; the registry only has to worry about snapshot-build and + // spawner-availability failures surfaced from `checkCodexProviderStatus` + // below. + const adapter = yield* makeCodexAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); + + // Build a managed snapshot whose settings never change — mutations come + // in as instance rebuilds from the registry rather than in-place + // updates. Pre-provide `ChildProcessSpawner` so the check fits + // `makeManagedServerProvider.checkProvider`'s `R = never`. + const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingCodexProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts new file mode 100644 index 00000000000..12e98293b12 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -0,0 +1,213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { CodexSettings } from "@t3tools/contracts"; +import { + CodexShadowHomeError, + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./CodexHomeLayout.ts"; +const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); + +const decodeCodexSettings = (input: { + readonly enabled?: boolean; + readonly homePath?: string; + readonly shadowHomePath?: string; + readonly customModels?: readonly string[]; + readonly binaryPath?: string; +}): CodexSettings => decodeCodexSettingsValue(input); + +const makeTempDir = Effect.fn("CodexHomeLayout.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* ( + filePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); +}); + +it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { + describe("resolveCodexHomeLayout", () => { + it.effect("uses direct CODEX_HOME when no shadow home is configured", () => + Effect.gen(function* () { + const homePath = yield* makeTempDir("t3code-codex-home-"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath, + }), + ); + + expect(layout).toMatchObject({ + mode: "direct", + sharedHomePath: homePath, + effectiveHomePath: homePath, + continuationKey: `codex:home:${homePath}`, + }); + }), + ); + + it.effect("uses the shared home for continuation and the shadow home for runtime", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + expect(layout).toMatchObject({ + mode: "authOverlay", + sharedHomePath: sharedHome, + effectiveHomePath: shadowHome, + continuationKey: `codex:home:${sharedHome}`, + }); + }), + ); + }); + + describe("materializeCodexShadowHome", () => { + it.effect("materializes a shadow home with shared state links and private auth", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions")); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n'); + yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); + yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* fileSystem.symlink( + path.join(sharedHome, "models_cache.json"), + path.join(shadowHome, "models_cache.json"), + ); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); + const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const modelsCacheExists = yield* fileSystem.exists( + path.join(shadowHome, "models_cache.json"), + ); + const authLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "auth.json")) + .pipe(Effect.result); + const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); + + expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); + expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(modelsCacheExists).toBe(false); + expect(authLinkResult._tag).toBe("Failure"); + expect(authContents).toContain("shadow"); + }), + ); + + it.effect("accepts Codex-created shadow-local runtime directories", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* fileSystem.makeDirectory(path.join(sharedHome, "log")); + yield* fileSystem.makeDirectory(path.join(sharedHome, "memories")); + yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp")); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true }); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const logLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "log")) + .pipe(Effect.result); + const memoriesLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "memories")) + .pipe(Effect.result); + const tmpLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "tmp")) + .pipe(Effect.result); + + expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(logLinkResult._tag).toBe("Failure"); + expect(memoriesLinkResult._tag).toBe("Failure"); + expect(tmpLinkResult._tag).toBe("Failure"); + }), + ); + + it.effect("rejects shadow homes that point at the shared home", () => + Effect.gen(function* () { + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: sharedHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CodexShadowHomeError); + }), + ); + + it.effect("rejects shared entries that already exist in the shadow home as real files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + + expect(error.detail).toContain("already exists and is not a symlink"); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts new file mode 100644 index 00000000000..5a7132224ef --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -0,0 +1,267 @@ +import * as NodeOS from "node:os"; + +import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as PlatformError from "effect/PlatformError"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +export interface CodexHomeLayout { + readonly mode: "direct" | "authOverlay"; + readonly sharedHomePath: string; + readonly effectiveHomePath: string | undefined; + readonly continuationKey: string; +} + +const KNOWN_SHARED_DIRECTORIES = [ + "sessions", + "archived_sessions", + "sqlite", + "shell_snapshots", + "worktrees", + "skills", + "plugins", + "cache", + "logs", +] as const; + +const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); +const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); + +function resolveHomePath(path: Path.Path, value: string | undefined): string { + const expanded = + value && value.trim().length > 0 + ? expandHomePath(value) + : path.join(NodeOS.homedir(), ".codex"); + return path.resolve(expanded); +} + +export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(function* ( + config: CodexSettings, +): Effect.fn.Return { + const path = yield* Path.Path; + const sharedHomePath = resolveHomePath(path, config.homePath); + const shadowHomePath = config.shadowHomePath.trim(); + if (shadowHomePath.length === 0) { + return { + mode: "direct", + sharedHomePath, + effectiveHomePath: config.homePath.trim().length > 0 ? sharedHomePath : undefined, + continuationKey: `codex:home:${sharedHomePath}`, + }; + } + + const effectiveHomePath = path.resolve(expandHomePath(shadowHomePath)); + return { + mode: "authOverlay", + sharedHomePath, + effectiveHomePath, + continuationKey: `codex:home:${sharedHomePath}`, + }; +}); + +export class CodexShadowHomeError extends Schema.TaggedErrorClass()( + "CodexShadowHomeError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return this.detail; + } +} +const isCodexShadowHomeError = Schema.is(CodexShadowHomeError); + +type LinkState = + | { + readonly _tag: "Missing"; + } + | { + readonly _tag: "NotSymlink"; + } + | { + readonly _tag: "Symlink"; + readonly target: string; + }; + +function toShadowHomeError(cause: unknown): CodexShadowHomeError { + return isCodexShadowHomeError(cause) + ? cause + : new CodexShadowHomeError({ + detail: "Failed to materialize Codex shadow home.", + cause, + }); +} + +function normalizeShadowHomeError( + effect: Effect.Effect, +): Effect.Effect { + return effect.pipe(Effect.mapError(toShadowHomeError)); +} + +function isNotSymlinkError(error: PlatformError.PlatformError): boolean { + const cause = error.reason.cause; + return ( + error.reason._tag === "Unknown" && + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "EINVAL" + ); +} + +const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( + fileSystem: FileSystem.FileSystem, + linkPath: string, +): Effect.fn.Return { + return yield* fileSystem.readLink(linkPath).pipe( + Effect.map((target): LinkState => ({ _tag: "Symlink", target })), + Effect.catch((error) => { + if (error.reason._tag === "NotFound") { + return Effect.succeed({ _tag: "Missing" }); + } + if (isNotSymlinkError(error)) { + return Effect.succeed({ _tag: "NotSymlink" }); + } + return Effect.fail(toShadowHomeError(error)); + }), + ); +}); + +const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly shadowPath: string; + readonly entryName: string; +}): Effect.fn.Return { + const path = yield* Path.Path; + const privatePath = path.join(input.shadowPath, input.entryName); + const state = yield* readLinkState(input.fileSystem, privatePath); + if (state._tag === "Symlink") { + yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); + } +}); + +const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly shadowPath: string; + readonly sharedPath: string; + readonly entryName: string; +}): Effect.fn.Return { + const path = yield* Path.Path; + const target = path.join(input.sharedPath, input.entryName); + const link = path.join(input.shadowPath, input.entryName); + const state = yield* readLinkState(input.fileSystem, link); + + if (state._tag === "NotSymlink") { + return yield* new CodexShadowHomeError({ + detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, + }); + } + + if (state._tag === "Missing") { + return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + } + + const resolvedExisting = path.resolve(path.dirname(link), state.target); + if (resolvedExisting !== target) { + yield* normalizeShadowHomeError(input.fileSystem.remove(link)); + yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); + } +}); + +const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( + fileSystem: FileSystem.FileSystem, + shadowPath: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const authPath = path.join(shadowPath, "auth.json"); + const state = yield* readLinkState(fileSystem, authPath); + if (state._tag === "Symlink") { + return yield* new CodexShadowHomeError({ + detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, + }); + } +}); + +export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( + layout: CodexHomeLayout, +) { + if (layout.mode !== "authOverlay") return; + const effectiveHomePath = layout.effectiveHomePath; + if (!effectiveHomePath) return; + if (layout.sharedHomePath === effectiveHomePath) { + return yield* new CodexShadowHomeError({ + detail: "Codex shadow home path must be different from the shared home path.", + }); + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* normalizeShadowHomeError( + Effect.all( + [ + fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), + fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), + ...KNOWN_SHARED_DIRECTORIES.map((directory) => + fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { + recursive: true, + }), + ), + ], + { concurrency: "unbounded" }, + ), + ); + + const sharedEntryNames = yield* normalizeShadowHomeError( + fileSystem.readDirectory(layout.sharedHomePath), + ); + const entries = new Set(KNOWN_SHARED_DIRECTORIES); + for (const entryName of sharedEntryNames) { + if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { + entries.add(entryName); + } + } + + yield* Effect.forEach( + PRIVATE_ENTRY_NAMES, + (entryName) => + entryName === "auth.json" + ? Effect.void + : removePrivateSymlink({ + fileSystem, + shadowPath: effectiveHomePath, + entryName, + }), + { discard: true }, + ); + + yield* Effect.forEach( + entries, + (entryName) => { + if (PRIVATE_ENTRY_NAMES.has(entryName)) { + return Effect.void; + } + return ensureSymlink({ + fileSystem, + shadowPath: effectiveHomePath, + sharedPath: layout.sharedHomePath, + entryName, + }); + }, + { discard: true }, + ); + + yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); +}); + +export function codexContinuationIdentity(layout: CodexHomeLayout) { + return { + driverKind: ProviderDriverKind.make("codex"), + continuationKey: layout.continuationKey, + }; +} diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts new file mode 100644 index 00000000000..b399f9aa948 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -0,0 +1,179 @@ +/** + * CursorDriver — `ProviderDriver` for the Cursor Agent (`agent`) runtime. + * + * Cursor exposes an ACP-based CLI. The driver is still a plain value, but + * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` + * hook to run the slow ACP model-capability probe in the background without + * blocking the initial `ready`-state publish. + * + * Text generation is supported via the ACP runtime — `makeCursorTextGeneration` + * drives `runtime.prompt` with a structured-output schema and collects the + * agent's `agent_message_chunk` stream into a single JSON blob. + * + * @module provider/Drivers/CursorDriver + */ +import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; +import { + buildInitialCursorProviderSnapshot, + checkCursorProviderStatus, + enrichCursorSnapshot, +} from "../Layers/CursorProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +const decodeCursorSettings = Schema.decodeSync(CursorSettings); + +const DRIVER_KIND = ProviderDriverKind.make("cursor"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: "agent", + updateArgs: ["update"], + updateLockKey: "cursor-agent", + }), +); + +export type CursorDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const CursorDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Cursor", + supportsMultipleInstances: true, + }, + configSchema: CursorSettings, + defaultConfig: (): CursorSettings => decodeCursorSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies CursorSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeCursorAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + // Preserve the background ACP model-capability probe that used to + // live on `CursorProviderLive`. Only fires when the snapshot reports + // an authenticated, enabled provider with at least one non-custom + // model whose capabilities haven't been captured yet. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichCursorSnapshot({ + settings, + environment: processEnv, + snapshot: currentSnapshot, + maintenanceCapabilities, + publishSnapshot, + stampIdentity, + httpClient, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts new file mode 100644 index 00000000000..816e8b70f55 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -0,0 +1,181 @@ +/** + * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. + * + * Mirrors the Codex / Claude drivers: a plain value whose `create()` + * bundles `snapshot` / `adapter` / `textGeneration` closures over the + * per-instance `OpenCodeSettings`. + * + * Two instances with different `serverUrl`s therefore talk to independent + * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation + * shares spin up their own scoped child processes, and those child + * processes are released when the registry scope closes. + * + * @module provider/Drivers/OpenCodeDriver + */ +import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; +import { + checkOpenCodeProviderStatus, + makePendingOpenCodeProvider, +} from "../Layers/OpenCodeProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +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); + return ( + normalized.endsWith("/.opencode/bin/opencode") || + normalized.endsWith("/.opencode/bin/opencode.exe") + ); +} + +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "opencode-ai", + homebrewFormula: "anomalyco/tap/opencode", + nativeUpdate: { + executable: "opencode", + args: ["upgrade"], + lockKey: "opencode-native", + isCommandPath: isOpenCodeNativeCommandPath, + }, +}); + +export type OpenCodeDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | OpenCodeRuntime + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OpenCodeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenCode", + supportsMultipleInstances: true, + }, + configSchema: OpenCodeSettings, + defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const openCodeRuntime = yield* OpenCodeRuntime; + const serverConfig = yield* ServerConfig; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkOpenCodeProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingOpenCodeProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index e4e46d37486..bccc7d5679c 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import type { CheckpointServiceError } from "../checkpointing/Errors.ts"; @@ -116,6 +116,46 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass()( + "ProviderInstanceNotFoundError", + { + instanceId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `No provider instance bound to id '${this.instanceId}'`; + } +} + +/** + * ProviderDriverError - A driver `create` call failed before producing an + * instance. Surfaced to the registry, which marks the offending entry as + * an "unavailable" shadow snapshot rather than crashing the server. + */ +export class ProviderDriverError extends Schema.TaggedErrorClass()( + "ProviderDriverError", + { + driver: Schema.String, + instanceId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; + } +} + /** * ProviderSessionNotFoundError - Provider-facing session not found. */ @@ -157,6 +197,7 @@ export type ProviderAdapterError = export type ProviderServiceError = | ProviderValidationError | ProviderUnsupportedError + | ProviderInstanceNotFoundError | ProviderSessionNotFoundError | ProviderSessionDirectoryPersistenceError | ProviderAdapterError diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 8b1f1389c41..19ed1811760 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -12,20 +13,37 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, + ClaudeSettings, + ProviderDriverKind, ProviderItemId, ProviderRuntimeEvent, type RuntimeMode, ThreadId, + ProviderInstanceId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, Fiber, Layer, Random, Stream } from "effect"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +// Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. +class ClaudeAdapter extends Context.Service()( + "test/ClaudeAdapter", +) {} class FakeClaudeQuery implements AsyncIterable { private readonly queue: Array = []; @@ -136,6 +154,8 @@ function makeHarness(config?: { readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; readonly cwd?: string; readonly baseDir?: string; + readonly claudeConfig?: Partial; + readonly instanceId?: ProviderInstanceId; }) { const query = new FakeClaudeQuery(); let createInput: @@ -146,6 +166,7 @@ function makeHarness(config?: { | undefined; const adapterOptions: ClaudeAdapterLiveOptions = { + ...(config?.instanceId ? { instanceId: config.instanceId } : {}), createQuery: (input) => { createInput = input; return query; @@ -163,7 +184,13 @@ function makeHarness(config?: { }; return { - layer: makeClaudeAdapterLive(adapterOptions).pipe( + layer: Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings(config?.claudeConfig ?? {}); + return yield* makeClaudeAdapter(claudeConfig, adapterOptions); + }), + ).pipe( Layer.provideMerge( ServerConfig.layerTest( config?.cwd ?? "/tmp/claude-adapter-test", @@ -246,7 +273,11 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; const result = yield* adapter - .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) + .startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("codex"), + runtimeMode: "full-access", + }) .pipe(Effect.result); assert.equal(result._tag, "Failure"); @@ -256,7 +287,7 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual( result.failure, new ProviderAdapterValidationError({ - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "startSession", issue: "Expected provider 'claudeAgent' but received 'codex'.", }), @@ -273,7 +304,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -293,7 +324,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -313,7 +344,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -332,13 +363,55 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("runs Claude SDK sessions with the configured Claude HOME", () => { + const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.env?.HOME, path.join(os.homedir(), ".claude-work")); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-7", }, runtimeMode: "full-access", }); @@ -351,20 +424,41 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-7", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "max", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -382,14 +476,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - effort: "high", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-haiku-4-5", + [{ id: "effort", value: "high" }], + ), runtimeMode: "full-access", }); @@ -407,14 +499,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-haiku-4-5", + [{ id: "thinking", value: false }], + ), runtimeMode: "full-access", }); @@ -434,14 +524,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - thinking: false, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "thinking", value: false }], + ), runtimeMode: "full-access", }); @@ -459,14 +547,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - fastMode: true, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "fastMode", value: true }], + ), runtimeMode: "full-access", }); @@ -486,14 +572,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - fastMode: true, - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "fastMode", value: true }], + ), runtimeMode: "full-access", }); @@ -511,14 +595,12 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ), runtimeMode: "full-access", }); @@ -526,13 +608,11 @@ describe("ClaudeAdapterLive", () => { threadId: session.threadId, input: "Investigate the edge cases", attachments: [], - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { - effort: "ultrathink", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ), }); const createInput = harness.getLastCreateQueryInput(); @@ -577,7 +657,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -622,9 +702,9 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-5", }, runtimeMode: "full-access", @@ -799,7 +879,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -966,6 +1046,97 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("falls back to a default plan step label for blank TodoWrite content", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-todo-1", + name: "TodoWrite", + input: {}, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-input", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 1, + delta: { + type: "input_json_delta", + partial_json: + '{"todos":[{"content":" ","status":"in_progress"},{"content":"Ship it","status":"completed"}]}', + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-todo-plan", + uuid: "result-todo-plan", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + assert.equal(planUpdated?.type, "turn.plan.updated"); + if (planUpdated?.type === "turn.plan.updated") { + assert.equal(String(planUpdated.turnId), String(turn.turnId)); + assert.deepEqual(planUpdated.payload.plan, [ + { step: "Task", status: "inProgress" }, + { step: "Ship it", status: "completed" }, + ]); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -978,7 +1149,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1054,7 +1225,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1120,7 +1291,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1169,6 +1340,77 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("closes the previous session before replacing an existing thread session", () => { + const queries: FakeClaudeQuery[] = []; + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const secondSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + resumeCursor: firstSession.resumeCursor, + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const activeSessions = yield* adapter.listSessions(); + + assert.equal(queries.length, 2); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 0); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(activeSessions.length, 1); + assert.deepEqual(activeSessions[0]?.resumeCursor, secondSession.resumeCursor); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "session.started", + "session.configured", + "session.state.changed", + ], + ); + assert.equal( + runtimeEvents.some((event) => event.type === "session.exited"), + false, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("stopSession does not throw into the SDK prompt consumer", () => { // The SDK consumes user messages via `for await (... of prompt)`. // Stopping a session must end that loop cleanly — not throw an error. @@ -1183,21 +1425,27 @@ describe("ClaudeAdapterLive", () => { let promptConsumerError: unknown = undefined; - const layer = makeClaudeAdapterLive({ - createQuery: (input) => { - // Simulate the SDK consuming the prompt iterable - (async () => { - try { - for await (const _message of input.prompt) { - /* SDK processes user messages */ - } - } catch (error) { - promptConsumerError = error; - } - })(); - return query; - }, - }).pipe( + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: (input) => { + // Simulate the SDK consuming the prompt iterable + (async () => { + try { + for await (const _message of input.prompt) { + /* SDK processes user messages */ + } + } catch (error) { + promptConsumerError = error; + } + })(); + return query; + }, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), @@ -1215,7 +1463,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1224,7 +1472,8 @@ describe("ClaudeAdapterLive", () => { yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Effect.yieldNow; - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); + yield* TestClock.adjust("50 millis"); + yield* Effect.yieldNow; runtimeEventsFiber.interruptUnsafe(); @@ -1252,7 +1501,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1299,7 +1548,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1353,7 +1602,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1420,7 +1669,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1485,7 +1734,7 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1566,7 +1815,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1657,7 +1906,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1823,7 +2072,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -1892,7 +2141,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2114,7 +2363,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); assert.equal(session.threadId, THREAD_ID); @@ -2187,7 +2436,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -2296,7 +2545,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -2369,7 +2618,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: RESUME_THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), resumeCursor: { threadId: "resume-thread-1", resume: "550e8400-e29b-41d4-a716-446655440000", @@ -2397,6 +2646,96 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves durable resume ids across Claude resume hooks", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const durableSessionId = "550e8400-e29b-41d4-a716-446655440000"; + const transientHookSessionId = "7368d0c7-40a3-4d8a-bcc1-ac80c49f2719"; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { + threadId: RESUME_THREAD_ID, + resume: durableSessionId, + resumeSessionAt: "assistant-99", + turnCount: 3, + }, + runtimeMode: "full-access", + }); + + harness.query.emit({ + type: "system", + subtype: "hook_started", + hook_id: "resume-hook-1", + hook_name: "SessionStart:resume", + hook_event: "SessionStart", + session_id: transientHookSessionId, + uuid: "resume-hook-started", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "hook_response", + hook_id: "resume-hook-1", + hook_name: "SessionStart:resume", + hook_event: "SessionStart", + output: "", + stdout: "", + stderr: "", + outcome: "success", + session_id: transientHookSessionId, + uuid: "resume-hook-response", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "init", + apiKeySource: "none", + claude_code_version: "test", + cwd: "/tmp/claude-adapter-test", + tools: [], + mcp_servers: [], + model: "claude-sonnet-4-5", + permissionMode: "bypassPermissions", + slash_commands: [], + output_style: "default", + skills: [], + plugins: [], + session_id: durableSessionId, + uuid: "resume-init", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const threadStartedEvents = runtimeEvents.filter((event) => event.type === "thread.started"); + assert.equal(threadStartedEvents.length, 1); + const threadStarted = threadStartedEvents[0]; + assert.equal(threadStarted?.type, "thread.started"); + if (threadStarted?.type === "thread.started") { + assert.deepEqual(threadStarted.payload, { + providerThreadId: durableSessionId, + }); + } + + const activeSessions = yield* adapter.listSessions(); + const resumeCursor = activeSessions[0]?.resumeCursor as + | { + readonly resume?: string; + } + | undefined; + assert.equal(resumeCursor?.resume, durableSessionId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("uses an app-generated Claude session id for fresh sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2404,7 +2743,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2438,7 +2777,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2518,14 +2857,14 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, attachments: [], @@ -2538,6 +2877,34 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("updates model on sendTurn for the adapter's bound custom instance id", () => { + const customInstanceId = ProviderInstanceId.make("claude_openrouter"); + const harness = makeHarness({ instanceId: customInstanceId }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + modelSelection: { + instanceId: customInstanceId, + model: "openai/gpt-5.5", + }, + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, ["openai/gpt-5.5"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect( "does not re-set the Claude model when the session already uses the same effective API model", () => { @@ -2545,13 +2912,13 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; const modelSelection = { - provider: "claudeAgent" as const, + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }; const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), modelSelection, runtimeMode: "full-access", }); @@ -2584,27 +2951,25 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - contextWindow: "1m", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "contextWindow", value: "1m" }], + ), attachments: [], }); yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", modelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }, attachments: [], @@ -2624,7 +2989,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ @@ -2654,7 +3019,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode, }); @@ -2706,7 +3071,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ @@ -2729,7 +3094,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2795,7 +3160,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -2867,7 +3232,7 @@ describe("ClaudeAdapterLive", () => { // Start session in approval-required mode so canUseTool fires. const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -2941,6 +3306,9 @@ describe("ClaudeAdapterLive", () => { assert.equal(typeof requestId, "string"); assert.equal(requestedEvent.value.payload.questions.length, 1); assert.equal(requestedEvent.value.payload.questions[0]?.question, "Which framework?"); + // Regression for #2388: `id` must equal the full question text so the + // UI's draft-answer key matches what the SDK looks up downstream. + assert.equal(requestedEvent.value.payload.questions[0]?.id, "Which framework?"); assert.deepEqual(requestedEvent.value.providerRefs, { providerItemId: ProviderItemId.make("tool-ask-1"), }); @@ -2975,6 +3343,34 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(updatedInput.answers, { "Which framework?": "React" }); // Original questions should be passed through. assert.deepEqual(updatedInput.questions, askInput.questions); + + // Compatibility check for #2388: the answers shape we hand to the SDK + // must produce a non-empty rendered tool_result on BOTH SDK iteration + // patterns we have seen, so we don't regress the issue and we don't + // break users still on the older Claude CLI. + const sdkAnswers = updatedInput.answers as Record; + const sdkQuestions = updatedInput.questions as ReadonlyArray<{ + readonly question: string; + }>; + + // Claude CLI 2.1.119 — key-agnostic Object.entries iteration. Any key + // works here, but it must at least round-trip into a non-empty string. + const v119Rendered = Object.entries(sdkAnswers) + .map(([key, value]) => `"${key}"="${String(value)}"`) + .join(", "); + assert.equal(v119Rendered, '"Which framework?"="React"'); + + // Claude CLI 2.1.121 — lookup by full question text. This is the path + // that regressed in #2388 when the answers were keyed by `header`. + const v121Rendered = sdkQuestions + .map(({ question }) => { + const answer = sdkAnswers[question]; + return answer === undefined ? null : `"${question}"="${String(answer)}"`; + }) + .filter((entry): entry is string => entry !== null) + .join(", "); + assert.notEqual(v121Rendered, "", "Expected non-empty SDK 2.1.121 tool_result (#2388)"); + assert.equal(v121Rendered, '"Which framework?"="React"'); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -2990,7 +3386,7 @@ describe("ClaudeAdapterLive", () => { // AskUserQuestion should still go through the user-input flow. const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); @@ -3056,7 +3452,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "approval-required", }); @@ -3143,7 +3539,7 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 1a0657c499d..cf97b0a87f0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -6,26 +6,28 @@ * * @module ClaudeAdapterLive */ -import { - type CanUseTool, - query, - type Options as ClaudeQueryOptions, - type PermissionMode, - type PermissionResult, - type PermissionUpdate, - type SDKMessage, - type SDKResultMessage, - type SettingSource, - type SDKUserMessage, +import type { + CanUseTool, + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + PermissionUpdate, + SDKMessage, + SDKResultMessage, + SettingSource, + SDKUserMessage, ModelUsage, - NonNullableUsage, } from "@anthropic-ai/claude-agent-sdk"; +import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { ApprovalRequestId, type CanonicalItemType, type CanonicalRequestType, + type ClaudeSettings, EventId, type ProviderApprovalDecision, + ProviderDriverKind, + ProviderInstanceId, ProviderItemId, type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, @@ -40,33 +42,38 @@ import { ThreadId, TurnId, type UserInputQuestion, - ClaudeCodeEffort, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - resolveApiModelId, - resolveEffort, - trimOrNull, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, + resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { - Cause, - DateTime, - Deferred, - Effect, - Exit, - FileSystem, - Fiber, - Layer, - Queue, - Random, - Ref, - Stream, -} from "effect"; +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 Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; +import { makeClaudeCliQuery } from "./ClaudeCliTransport.ts"; +import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -75,15 +82,23 @@ import { ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; -import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString); -const PROVIDER = "claudeAgent" as const; +const PROVIDER = ProviderDriverKind.make("claudeAgent"); type ClaudeTextStreamKind = Extract; type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, "command_output" | "file_change_output" >; +type ClaudeSdkEffort = NonNullable; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} type PromptQueueItem = | { @@ -176,6 +191,8 @@ interface ClaudeQueryRuntime extends AsyncIterable { } export interface ClaudeAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; readonly createQuery?: (input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; @@ -192,6 +209,18 @@ function isSyntheticClaudeThreadId(value: string): boolean { return value.startsWith("claude-thread-"); } +function hasDurableClaudeSessionId(message: SDKMessage): boolean { + if (message.type !== "system") { + return true; + } + + return ( + message.subtype !== "hook_started" && + message.subtype !== "hook_progress" && + message.subtype !== "hook_response" + ); +} + function toMessage(cause: unknown, fallback: string): string { if (cause instanceof Error && cause.message.length > 0) { return cause.message; @@ -199,11 +228,22 @@ function toMessage(cause: unknown, fallback: string): string { return fallback; } -function toError(cause: unknown, fallback: string): Error { - return cause instanceof Error ? cause : new Error(toMessage(cause, fallback)); +function toProcessError( + cause: unknown, + fallback: string, + threadId: ThreadId, +): ProviderAdapterProcessError { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, fallback), + cause, + }); } -function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray { +function normalizeClaudeStreamMessages( + cause: Cause.Cause<{ readonly message: string }>, +): ReadonlyArray { const errors = Cause.prettyErrors(cause) .map((error) => error.message.trim()) .filter((message) => message.length > 0); @@ -215,13 +255,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeCodeEffort( - effort: ClaudeCodeEffort | null | undefined, -): Exclude | null { - if (!effort) { - return null; - } - return effort === "ultrathink" ? null : effort; +function getEffectiveClaudeAgentEffort(effort: string | null | undefined): ClaudeSdkEffort | null { + const normalized = normalizeClaudeCliEffort(effort); + return normalized ? (normalized as ClaudeSdkEffort) : null; } function isClaudeInterruptedMessage(message: string): boolean { @@ -233,18 +269,23 @@ function isClaudeInterruptedMessage(message: string): boolean { ); } -function isClaudeInterruptedCause(cause: Cause.Cause): boolean { +function isClaudeInterruptedCause(cause: Cause.Cause<{ readonly message: string }>): boolean { return ( Cause.hasInterruptsOnly(cause) || normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) ); } -function messageFromClaudeStreamCause(cause: Cause.Cause, fallback: string): string { +function messageFromClaudeStreamCause( + cause: Cause.Cause<{ readonly message: string }>, + fallback: string, +): string { return normalizeClaudeStreamMessages(cause)[0] ?? fallback; } -function interruptionMessageFromClaudeCause(cause: Cause.Cause): string { +function interruptionMessageFromClaudeCause( + cause: Cause.Cause<{ readonly message: string }>, +): string { const message = messageFromClaudeStreamCause(cause, "Claude runtime interrupted."); return isClaudeInterruptedMessage(message) ? "Claude runtime interrupted." : message; } @@ -289,7 +330,7 @@ function maxClaudeContextWindowFromModelUsage( } function normalizeClaudeTokenUsage( - value: NonNullableUsage | undefined, + value: unknown, contextWindow?: number, ): ThreadTokenUsageSnapshot | undefined { if (!value || typeof value !== "object") { @@ -459,6 +500,37 @@ function classifyRequestType(toolName: string): CanonicalRequestType { : "dynamic_tool_call"; } +function isTodoTool(toolName: string): boolean { + return toolName.toLowerCase().includes("todowrite"); +} + +type PlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; + +function extractPlanStepsFromTodoInput(input: Record): PlanStep[] | null { + // TodoWrite format: { todos: [{ content, status, activeForm? }] } + const todos = input.todos; + if (!Array.isArray(todos) || todos.length === 0) { + return null; + } + return todos + .filter((t): t is Record => t !== null && typeof t === "object") + .map((todo) => ({ + step: + typeof todo.content === "string" && todo.content.trim().length > 0 + ? todo.content.trim() + : "Task", + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "inProgress" + : "pending", + })); +} + function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; @@ -466,7 +538,21 @@ function summarizeToolRequest(toolName: string, input: Record): return `${toolName}: ${command.trim().slice(0, 400)}`; } - const serialized = JSON.stringify(input); + // For agent/subagent tools, prefer human-readable description or prompt over raw JSON + const itemType = classifyToolItemType(toolName); + if (itemType === "collab_agent_tool_call") { + const description = + typeof input.description === "string" ? input.description.trim() : undefined; + const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; + const subagentType = + typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; + const label = description || (prompt ? prompt.slice(0, 200) : undefined); + if (label) { + return subagentType ? `${subagentType}: ${label}` : label; + } + } + + const serialized = encodeJsonStringForDiagnostics(input) ?? "[unserializable input]"; if (serialized.length <= 400) { return `${toolName}: ${serialized}`; } @@ -506,18 +592,19 @@ const CLAUDE_SETTING_SOURCES = [ "local", ] as const satisfies ReadonlyArray; -function buildPromptText(input: ProviderSendTurnInput): string { +function buildPromptText( + input: ProviderSendTurnInput, + boundInstanceId: ProviderInstanceId, +): string { const rawEffort = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; + input.modelSelection?.instanceId === boundInstanceId + ? getModelSelectionStringOptionValue(input.modelSelection, "effort") + : null; const claudeModel = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); - // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). - // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. - const trimmedEffort = trimOrNull(rawEffort); - const promptEffort = - trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; + const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -554,9 +641,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( dependencies: { readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; + readonly boundInstanceId: ProviderInstanceId; }, ) { - const text = buildPromptText(input); + const text = buildPromptText(input, dependencies.boundInstanceId); const sdkContent: Array> = []; if (text.length > 0) { @@ -729,22 +817,18 @@ function exitPlanCaptureKey(input: { } function tryParseJsonRecord(value: string): Record | undefined { - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : undefined; - } catch { + const result = decodeUnknownJsonStringExit(value); + if (!Exit.isSuccess(result)) { return undefined; } + const parsed = result.value; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; } function toolInputFingerprint(input: Record): string | undefined { - try { - return JSON.stringify(input); - } catch { - return undefined; - } + return encodeJsonStringForDiagnostics(input); } function toolResultStreamKind(itemType: CanonicalItemType): ClaudeToolResultStreamKind | undefined { @@ -906,11 +990,17 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { return undefined; } -const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( +export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( + claudeSettings: ClaudeSettings, options?: ClaudeAdapterLiveOptions, ) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( + Effect.provideService(Path.Path, path), + ); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -924,11 +1014,14 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ((input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; - }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + }) => + makeClaudeCliQuery({ + prompt: input.prompt, + options: input.options, + }) as ClaudeQueryRuntime); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); - const serverSettingsService = yield* ServerSettingsService; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); @@ -945,7 +1038,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - const observedAt = new Date().toISOString(); + const observedAt = yield* nowIso; const itemId = sdkNativeItemId(message); yield* nativeEventLogger.write( @@ -955,7 +1048,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( id: "uuid" in message && typeof message.uuid === "string" ? message.uuid - : crypto.randomUUID(), + : yield* Random.nextUUIDv4, kind: "notification", provider: PROVIDER, createdAt: observedAt, @@ -963,7 +1056,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof message.session_id === "string" ? { providerThreadId: message.session_id } : {}), - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), ...(itemId ? { itemId: ProviderItemId.make(itemId) } : {}), payload: message, }, @@ -1196,6 +1293,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (typeof message.session_id !== "string" || message.session_id.length === 0) { return; } + if (!hasDurableClaudeSessionId(message)) { + return; + } const nextThreadId = message.session_id; context.resumeSessionId = message.session_id; yield* updateResumeCursor(context); @@ -1352,7 +1452,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof accumulatedTotalProcessedTokens === "number" && Number.isFinite(accumulatedTotalProcessedTokens) && accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens - ? { totalProcessedTokens: accumulatedTotalProcessedTokens } + ? { + totalProcessedTokens: accumulatedTotalProcessedTokens, + } : {}), } : accumulatedSnapshot; @@ -1416,7 +1518,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: tool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/result", @@ -1538,7 +1642,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( threadId: context.session.threadId, turnId: context.turnState.turnId, ...(assistantBlockEntry?.block - ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } + ? { + itemId: asRuntimeItemId(assistantBlockEntry.block.itemId), + } : {}), payload: { streamKind, @@ -1597,7 +1703,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), itemId: asRuntimeItemId(nextTool.itemId), payload: { itemType: nextTool.itemType, @@ -1609,13 +1719,39 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: nextTool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: nextTool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_delta/input_json_delta", payload: message, }, }); + + // Emit plan update when TodoWrite input is parsed + if (parsedInput && isTodoTool(nextTool.toolName)) { + const planSteps = extractPlanStepsFromTodoInput(parsedInput); + if (planSteps && planSteps.length > 0) { + const planStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.plan.updated", + eventId: planStamp.eventId, + provider: PROVIDER, + createdAt: planStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), + payload: { + plan: planSteps, + }, + providerRefs: nativeProviderRefs(context), + }); + } + } } return; } @@ -1678,7 +1814,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: toolInput, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_start", @@ -1750,7 +1888,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -1773,7 +1913,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( streamKind, delta: toolResult.text, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -1798,7 +1940,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -2154,7 +2298,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( payload: { summary: message.summary, ...(message.preceding_tool_use_ids.length > 0 - ? { precedingToolUseIds: message.preceding_tool_use_ids } + ? { + precedingToolUseIds: message.preceding_tool_use_ids, + } : {}), }, }); @@ -2225,9 +2371,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } }); - const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => + const runSdkStream = ( + context: ClaudeSessionContext, + ): Effect.Effect => Stream.fromAsyncIterable(context.query, (cause) => - toError(cause, "Claude runtime stream failed."), + toProcessError(cause, "Claude runtime stream failed.", context.session.threadId), ).pipe( Stream.takeWhile(() => !context.stopped), Stream.runForEach((message) => handleSdkMessage(context, message)), @@ -2235,7 +2383,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const handleStreamExit = Effect.fn("handleStreamExit")(function* ( context: ClaudeSessionContext, - exit: Exit.Exit, + exit: Exit.Exit, ) { if (context.stopped) { return; @@ -2304,12 +2452,20 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Fiber.interrupt(streamFiber); } - // @effect-diagnostics-next-line tryCatchInEffectGen:off - try { - context.query.close(); - } catch (cause) { - yield* emitRuntimeError(context, "Failed to close Claude runtime query.", cause); - } + yield* Effect.try({ + try: () => context.query.close(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: toMessage(cause, "Failed to close Claude runtime query."), + cause, + }), + }).pipe( + Effect.catch((cause) => + emitRuntimeError(context, "Failed to close Claude runtime query.", cause), + ), + ); const updatedAt = yield* nowIso; context.session = { @@ -2371,6 +2527,27 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + const existingContext = sessions.get(input.threadId); + if (existingContext) { + yield* Effect.logWarning("claude.session.replacing", { + threadId: input.threadId, + existingSessionStatus: existingContext.session.status, + reason: "startSession called with existing active session", + }); + yield* stopSessionInternal(existingContext, { + emitExitEvent: false, + }).pipe( + // Replacement cleanup is best-effort: never block the new session on + // either typed failures or unexpected defects from tearing down the old one. + Effect.catchCause((cause) => + Effect.logWarning("claude.session.replace.stop-failed", { + threadId: input.threadId, + cause, + }), + ), + ); + } + const startedAt = yield* nowIso; const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = input.threadId; @@ -2406,15 +2583,22 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const handleAskUserQuestion = Effect.fn("handleAskUserQuestion")(function* ( context: ClaudeSessionContext, toolInput: Record, - callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, + callbackOptions: { + readonly signal: AbortSignal; + readonly toolUseID?: string; + }, ) { const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); // Parse questions from the SDK's AskUserQuestion input. + // `id` MUST equal the full question text — Claude SDK >= 2.1.121 looks + // up answers by question text in `mapToolResultToToolResultBlockParam`, + // so the key the UI uses to keep its draft answer must match the SDK's + // expected lookup key. See https://github.com/pingdotgg/t3code/issues/2388 const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; const questions: Array = rawQuestions.map( (q: Record, idx: number) => ({ - id: typeof q.header === "string" ? q.header : `q-${idx}`, + id: typeof q.question === "string" && q.question.length > 0 ? q.question : `q-${idx}`, header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`, question: typeof q.question === "string" ? q.question : "", options: Array.isArray(q.options) @@ -2442,7 +2626,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: requestedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { questions }, providerRefs: nativeProviderRefs(context, { @@ -2451,7 +2639,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( raw: { source: "claude.sdk.permission", method: "canUseTool/AskUserQuestion", - payload: { toolName: "AskUserQuestion", input: toolInput }, + payload: { + toolName: "AskUserQuestion", + input: toolInput, + }, }, }); @@ -2466,7 +2657,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( pendingUserInputs.delete(requestId); runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); }; - callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -2480,7 +2673,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: resolvedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { answers }, providerRefs: nativeProviderRefs(context, { @@ -2650,7 +2847,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( behavior: "allow", updatedInput: toolInput, ...(decision === "acceptForSession" && pendingApproval.suggestions - ? { updatedPermissions: [...pendingApproval.suggestions] } + ? { + updatedPermissions: [...pendingApproval.suggestions], + } : {}), } satisfies PermissionResult; } @@ -2667,31 +2866,28 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - const claudeSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.claudeAgent), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); const claudeBinaryPath = claudeSettings.binaryPath; + const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); - const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; - const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeCodeEffort | null; - const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; - const thinking = - typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle - ? modelSelection.options.thinking - : undefined; - const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const descriptors = getProviderOptionDescriptors({ caps }); + const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; + const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); + const effort = resolveClaudeEffort(caps, rawEffort) ?? null; + const fastModeSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + const thinkingSupported = descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", + ); + const fastMode = + getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true && + fastModeSupported; + const thinking = thinkingSupported + ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") + : undefined; + const effectiveEffort = getEffectiveClaudeAgentEffort(effort); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", "full-access": "bypassPermissions", @@ -2701,13 +2897,19 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), }; - const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, + systemPrompt: { type: "preset", preset: "claude_code" }, settingSources: [...CLAUDE_SETTING_SOURCES], - ...(effectiveEffort ? { effort: effectiveEffort } : {}), + // The SDK type lags the CLI here: Opus 4.7 accepts `xhigh` even though + // the published `Options["effort"]` union currently stops at `max`. + ...(effectiveEffort + ? { + effort: effectiveEffort as unknown as NonNullable, + } + : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } @@ -2717,10 +2919,36 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: process.env, + env: claudeEnvironment, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; + yield* Effect.annotateCurrentSpan({ + "provider.kind": PROVIDER, + "provider.thread_id": threadId, + "provider.runtime_mode": input.runtimeMode, + "claude.resume.source": + existingResumeSessionId !== undefined ? "resume-session" : "generated-session", + "claude.resume.thread_id": resumeState?.threadId ?? "", + "claude.resume.session_id": existingResumeSessionId ?? "", + "claude.resume.session_at": resumeState?.resumeSessionAt ?? "", + "claude.resume.turn_count": resumeState?.turnCount ?? -1, + "claude.query.cwd": input.cwd ?? "", + "claude.query.model": apiModelId ?? "", + "claude.query.effort": effectiveEffort ?? "", + "claude.query.permission_mode": permissionMode ?? "", + "claude.query.allow_dangerously_skip_permissions": permissionMode === "bypassPermissions", + "claude.query.resume": existingResumeSessionId ?? "", + "claude.query.session_id": newSessionId ?? "", + "claude.query.include_partial_messages": true, + "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], + "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", + "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", + "claude.query.path_to_executable": claudeBinaryPath, + }); + const queryRuntime = yield* Effect.try({ try: () => createQuery({ @@ -2739,6 +2967,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const session: ProviderSession = { threadId, provider: PROVIDER, + providerInstanceId: boundInstanceId, status: "ready", runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), @@ -2850,7 +3079,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* requireSession(input.threadId); const modelSelection = - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId + ? input.modelSelection + : undefined; if (context.turnState) { // Auto-close a stale synthetic turn (from background agent responses @@ -2859,7 +3090,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (modelSelection?.model) { - const apiModelId = resolveApiModelId(modelSelection); + const apiModelId = resolveClaudeApiModelId(modelSelection); if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), @@ -2924,6 +3155,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const message = yield* buildUserMessageEffect(input, { fileSystem, attachmentsDir: serverConfig.attachmentsDir, + boundInstanceId, }); yield* Queue.offer(context.promptQueue, { @@ -3061,9 +3293,3 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, } satisfies ClaudeAdapterShape; }); - -export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter()); - -export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { - return Layer.effect(ClaudeAdapter, makeClaudeAdapter(options)); -} diff --git a/apps/server/src/provider/Layers/ClaudeCliTransport.test.ts b/apps/server/src/provider/Layers/ClaudeCliTransport.test.ts new file mode 100644 index 00000000000..8b7ed16e227 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCliTransport.test.ts @@ -0,0 +1,221 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; + +import { makeClaudeCliQuery } from "./ClaudeCliTransport.ts"; + +/** + * A fake `claude` binary (Node script) that speaks the stream-json control + * protocol just enough to exercise the transport end to end: + * - answers the `initialize` control request with account + commands, + * - on a user message, emits one assistant message then a result, + * - answers `interrupt`, + * - when FAKE_EMIT_PERMISSION=1, sends a `can_use_tool` control request and + * echoes the decision back as a system message. + */ +const FAKE_CLI = ` +let buffer = ""; +function send(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } + +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let i; + while ((i = buffer.indexOf("\\n")) !== -1) { + const line = buffer.slice(0, i).trim(); + buffer = buffer.slice(i + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + handle(msg); + } +}); + +function handle(msg) { + if (msg.type === "control_request" && msg.request?.subtype === "initialize") { + send({ + type: "control_response", + response: { + request_id: msg.request_id, + subtype: "success", + response: { + account: { email: "user@example.com", subscriptionType: "claude_pro_subscription", tokenSource: "oauth" }, + commands: [{ name: "compact", description: "Compact" }], + }, + }, + }); + if (process.env.FAKE_EMIT_PERMISSION === "1") { + send({ + type: "control_request", + request_id: "perm-1", + request: { subtype: "can_use_tool", tool_name: "Bash", input: { command: "ls" } }, + }); + } + return; + } + if (msg.type === "control_request" && msg.request?.subtype === "interrupt") { + send({ type: "control_response", response: { request_id: msg.request_id, subtype: "success", response: {} } }); + return; + } + if (msg.type === "control_response") { + // Our answer to the can_use_tool request — echo the decision out. + send({ type: "system", subtype: "permission_decision", decision: msg.response?.response }); + send({ type: "result", subtype: "success", is_error: false, session_id: "s1", uuid: "r-perm" }); + return; + } + if (msg.type === "user") { + send({ type: "system", subtype: "init", session_id: "s1" }); + send({ type: "assistant", message: { id: "a1", role: "assistant", content: [{ type: "text", text: "hi" }] }, session_id: "s1" }); + send({ type: "result", subtype: "success", is_error: false, session_id: "s1", uuid: "r1" }); + return; + } +} +`; + +function makeFakeCli(): { dir: string; cliPath: string; cleanup: () => void } { + const dir = mkdtempSync(path.join(os.tmpdir(), "claude-cli-transport-")); + const cliPath = path.join(dir, "fake-cli.mjs"); + writeFileSync(cliPath, FAKE_CLI, "utf8"); + return { dir, cliPath, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +async function* once(message: unknown): AsyncGenerator { + yield message; +} + +async function* never(signal: AbortSignal): AsyncGenerator { + await new Promise((resolve) => { + if (signal.aborted) resolve(); + else signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +describe("ClaudeCliTransport", () => { + it("streams assistant + result messages from a user prompt", async () => { + const fake = makeFakeCli(); + try { + const query = makeClaudeCliQuery({ + prompt: once({ + type: "user", + session_id: "", + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + parent_tool_use_id: null, + }) as AsyncIterable, + options: { pathToClaudeCodeExecutable: fake.cliPath } as never, + }); + + const types: string[] = []; + for await (const message of query) { + types.push((message as { type: string }).type); + if ((message as { type: string }).type === "result") break; + } + query.close(); + + assert.deepEqual(types, ["system", "assistant", "result"]); + } finally { + fake.cleanup(); + } + }); + + it("resolves initialize() with the account + commands payload", async () => { + const fake = makeFakeCli(); + const abort = new AbortController(); + try { + const query = makeClaudeCliQuery({ + prompt: never(abort.signal) as AsyncIterable, + options: { pathToClaudeCodeExecutable: fake.cliPath } as never, + }); + const init = await query.initialize(); + abort.abort(); + query.close(); + + assert.equal(init.account?.email, "user@example.com"); + assert.equal(init.account?.subscriptionType, "claude_pro_subscription"); + assert.equal(init.account?.tokenSource, "oauth"); + assert.deepEqual(init.commands, [{ name: "compact", description: "Compact" }]); + } finally { + fake.cleanup(); + } + }); + + it("round-trips an interrupt control request", async () => { + const fake = makeFakeCli(); + const abort = new AbortController(); + try { + const query = makeClaudeCliQuery({ + prompt: never(abort.signal) as AsyncIterable, + options: { pathToClaudeCodeExecutable: fake.cliPath } as never, + }); + await query.initialize(); + // Resolves only if the fake answered the control request by request_id. + await query.interrupt(); + abort.abort(); + query.close(); + assert.ok(true); + } finally { + fake.cleanup(); + } + }); + + it("bridges inbound can_use_tool to the canUseTool callback", async () => { + const fake = makeFakeCli(); + const abort = new AbortController(); + process.env.FAKE_EMIT_PERMISSION = "1"; + try { + let sawToolName: string | undefined; + const query = makeClaudeCliQuery({ + prompt: never(abort.signal) as AsyncIterable, + options: { + pathToClaudeCodeExecutable: fake.cliPath, + canUseTool: async (toolName: string) => { + sawToolName = toolName; + return { behavior: "allow", updatedInput: { command: "ls" } }; + }, + } as never, + }); + + let decision: unknown; + for await (const message of query) { + const m = message as { type: string; subtype?: string; decision?: unknown }; + if (m.type === "system" && m.subtype === "permission_decision") decision = m.decision; + if (m.type === "result") break; + } + abort.abort(); + query.close(); + + assert.equal(sawToolName, "Bash"); + assert.deepEqual(decision, { behavior: "allow", updatedInput: { command: "ls" } }); + } finally { + delete process.env.FAKE_EMIT_PERMISSION; + fake.cleanup(); + } + }); + + it("ends the iterator cleanly when the process exits", async () => { + const fake = makeFakeCli(); + try { + const query = makeClaudeCliQuery({ + prompt: once({ + type: "user", + session_id: "", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + parent_tool_use_id: null, + }) as AsyncIterable, + options: { pathToClaudeCodeExecutable: fake.cliPath } as never, + }); + + let count = 0; + for await (const _message of query) { + count += 1; + if (count > 10) break; + } + // Iterator returned (process exits after closing stdin) without hanging. + assert.ok(count >= 3); + } finally { + fake.cleanup(); + } + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeCliTransport.ts b/apps/server/src/provider/Layers/ClaudeCliTransport.ts new file mode 100644 index 00000000000..0c644a6b7ce --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCliTransport.ts @@ -0,0 +1,581 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalRandom:off +// @effect-diagnostics globalTimers:off +/** + * ClaudeCliTransport - SDK-free driver for the native `claude` binary. + * + * Replaces `@anthropic-ai/claude-agent-sdk`'s `query()` runtime with a direct + * child-process spawn that speaks the binary's `stream-json` IPC protocol. + * The shape it returns is intentionally identical to the SDK's query object + * (`AsyncIterable` plus `interrupt/setModel/setPermissionMode/ + * setMaxThinkingTokens/close`) so it drops straight into the existing + * `createQuery` seam in {@link module:ClaudeAdapterLive} with no downstream + * changes. + * + * Billing rationale: the SDK package is a *type-only* dependency after this + * change. No SDK runtime code executes, so usage bills against the local + * `claude` login (subscription) rather than pay-per-token API credits — as + * long as the caller does not inject ANTHROPIC_API_KEY into `options.env`. + * + * Wire protocol (reverse-engineered from sdk.mjs @ 0.2.111 — the contract we + * must match exactly): + * + * - Base flags: `--output-format stream-json --verbose --input-format + * stream-json`. With a `canUseTool` callback the SDK adds + * `--permission-prompt-tool stdio`, so permission prompts arrive as + * `control_request{subtype:can_use_tool}` on stdout and are answered with + * a `control_response` on stdin. No separate MCP server is involved. + * - stdin (NDJSON): user messages; our control requests + * `{request_id,type:"control_request",request:{subtype}}`; our permission + * answers `{type:"control_response",response:{subtype:"success", + * request_id,response:}}`. + * - stdout (NDJSON): SDKMessage objects; `control_response`; inbound + * `control_request` (can_use_tool); `control_cancel_request`; plus + * `keep_alive` / `transcript_mirror` which are ignored. + * + * @module ClaudeCliTransport + */ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; + +import type { + CanUseTool, + Options as ClaudeQueryOptions, + PermissionResult, + SDKMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; + +/** + * The exact interface the Claude adapter expects back from `createQuery`. + * Mirrors the SDK's query object. + */ +export interface ClaudeCliQueryRuntime extends AsyncIterable { + readonly interrupt: () => Promise; + readonly setModel: (model?: string) => Promise; + readonly setPermissionMode: (mode: string) => Promise; + readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly close: () => void; + /** + * Resolves with the CLI's `initialize` control-response payload + * (`{ account, commands, models, ... }`). The handshake is sent + * automatically on startup; this returns the (memoised) result. Used by + * the capabilities/auth probe, which never sends a prompt. + */ + readonly initialize: () => Promise; +} + +export interface InitializeResult { + readonly account?: { + readonly email?: string; + readonly subscriptionType?: string; + readonly tokenSource?: string; + }; + readonly commands?: ReadonlyArray; + readonly models?: ReadonlyArray; + readonly [key: string]: unknown; +} + +export interface ClaudeCliQueryInput { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; +} + +/** Milliseconds to wait after closing stdin before escalating SIGTERM→SIGKILL. */ +const GRACEFUL_SHUTDOWN_MS = 2_000; +const SIGKILL_ESCALATION_MS = 3_000; + +/** Treat a path as a JS entrypoint (needs node/bun) if it has a JS/TS ext. */ +function isJsEntrypoint(path: string): boolean { + return [".js", ".mjs", ".cjs", ".tsx", ".ts", ".jsx"].some((ext) => path.endsWith(ext)); +} + +function defaultExecutable(): string { + return typeof (globalThis as { Bun?: unknown }).Bun !== "undefined" || + process.versions.bun !== undefined + ? "bun" + : "node"; +} + +function newRequestId(): string { + return Math.random().toString(36).substring(2, 15); +} + +/** + * Build the binary argv from `ClaudeQueryOptions`, mirroring the flag builder + * in the SDK. Only the options the Claude adapter actually sets are mapped; + * unknown extras flow through `extraArgs`. + */ +function buildArgs(options: ClaudeQueryOptions): string[] { + const args: string[] = [ + "--output-format", + "stream-json", + "--verbose", + "--input-format", + "stream-json", + ]; + + const o = options as ClaudeQueryOptions & { + readonly settings?: unknown; + readonly extraArgs?: Record; + readonly additionalDirectories?: ReadonlyArray; + }; + + if (o.effort) args.push("--effort", String(o.effort)); + if (o.model) args.push("--model", String(o.model)); + if (o.fallbackModel) args.push("--fallback-model", String(o.fallbackModel)); + + // A canUseTool callback routes permission prompts through stdio control IPC. + if (typeof o.canUseTool === "function") { + args.push("--permission-prompt-tool", "stdio"); + } + + if (o.resume) args.push("--resume", String(o.resume)); + if (o.sessionId) args.push("--session-id", String(o.sessionId)); + if (o.resumeSessionAt) args.push("--resume-session-at", String(o.resumeSessionAt)); + if (o.forkSession) args.push("--fork-session"); + if ((o as { persistSession?: boolean }).persistSession === false) { + args.push("--no-session-persistence"); + } + + const settingSources = o.settingSources; + if (settingSources !== undefined) { + args.push(`--setting-sources=${(settingSources as ReadonlyArray).join(",")}`); + } + + if (o.permissionMode) args.push("--permission-mode", String(o.permissionMode)); + if (o.allowDangerouslySkipPermissions) args.push("--allow-dangerously-skip-permissions"); + if (o.includePartialMessages) args.push("--include-partial-messages"); + + for (const dir of o.additionalDirectories ?? []) { + args.push("--add-dir", dir); + } + + // `settings` is a JSON blob the CLI accepts via --settings. + if (o.settings !== undefined) { + args.push( + "--settings", + typeof o.settings === "string" ? o.settings : JSON.stringify(o.settings), + ); + } + + // Pass-through extra flags: { "flag-name": value | null }. null = boolean flag. + for (const [flag, value] of Object.entries(o.extraArgs ?? {})) { + if (value === null) args.push(`--${flag}`); + else args.push(`--${flag}`, value); + } + + return args; +} + +/** + * Build the `initialize` control-request payload, mirroring the SDK. We don't + * use hooks / sdkMcpServers / jsonSchema, so those stay undefined; the + * systemPrompt and related fields flow through from options when present. + */ +function buildInitializePayload(options: ClaudeQueryOptions): Record { + const o = options as ClaudeQueryOptions & { + readonly systemPrompt?: unknown; + readonly appendSystemPrompt?: unknown; + readonly appendSubagentSystemPrompt?: unknown; + readonly excludeDynamicSections?: unknown; + readonly agents?: unknown; + readonly promptSuggestions?: unknown; + readonly agentProgressSummaries?: unknown; + }; + return { + subtype: "initialize", + hooks: undefined, + sdkMcpServers: undefined, + jsonSchema: undefined, + systemPrompt: typeof o.systemPrompt === "string" ? [o.systemPrompt] : o.systemPrompt, + appendSystemPrompt: o.appendSystemPrompt, + appendSubagentSystemPrompt: o.appendSubagentSystemPrompt, + excludeDynamicSections: o.excludeDynamicSections, + agents: o.agents, + promptSuggestions: o.promptSuggestions, + agentProgressSummaries: o.agentProgressSummaries, + }; +} + +interface AsyncQueue { + push: (value: T) => void; + end: () => void; + fail: (error: unknown) => void; + iterator: () => AsyncIterator; +} + +/** + * Unbounded async queue bridging push-based stdout framing to the pull-based + * `for await` the adapter consumes. Matches the buffering semantics of the + * SDK's internal message stream (and the test FakeClaudeQuery). + */ +function makeAsyncQueue(): AsyncQueue { + const buffer: T[] = []; + const waiters: Array<{ + resolve: (r: IteratorResult) => void; + reject: (e: unknown) => void; + }> = []; + let ended = false; + let failure: unknown | undefined; + + return { + push(value) { + if (ended) return; + const waiter = waiters.shift(); + if (waiter) waiter.resolve({ done: false, value }); + else buffer.push(value); + }, + end() { + if (ended) return; + ended = true; + for (const w of waiters.splice(0)) w.resolve({ done: true, value: undefined }); + }, + fail(error) { + if (ended) return; + ended = true; + failure = error; + for (const w of waiters.splice(0)) w.reject(error); + }, + iterator() { + return { + next() { + if (buffer.length > 0) { + return Promise.resolve({ done: false, value: buffer.shift() as T }); + } + if (failure !== undefined) { + const err = failure; + failure = undefined; + return Promise.reject(err); + } + if (ended) return Promise.resolve({ done: true, value: undefined }); + return new Promise>((resolve, reject) => { + waiters.push({ resolve, reject }); + }); + }, + }; + }, + }; +} + +interface ControlRequestEnvelope { + readonly type: "control_request"; + readonly request_id: string; + readonly request: { + readonly subtype: string; + readonly tool_name?: string; + readonly input?: Record; + readonly permission_suggestions?: unknown; + readonly blocked_path?: string; + readonly decision_reason?: unknown; + readonly title?: string; + }; +} + +interface ControlResponseEnvelope { + readonly type: "control_response"; + readonly response: { + readonly request_id: string; + readonly subtype: "success" | "error"; + readonly error?: string; + readonly response?: unknown; + }; +} + +/** + * Start a `claude` session over stream-json IPC and return a runtime object + * shaped like the SDK's query. + */ +export function makeClaudeCliQuery(input: ClaudeCliQueryInput): ClaudeCliQueryRuntime { + const { options } = input; + const o = options as ClaudeQueryOptions & { + readonly pathToClaudeCodeExecutable?: string; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly executableArgs?: ReadonlyArray; + readonly canUseTool?: CanUseTool; + }; + + const binaryPath = o.pathToClaudeCodeExecutable ?? "claude"; + const flags = buildArgs(options); + const executableArgs = [...(o.executableArgs ?? [])]; + + const isJs = isJsEntrypoint(binaryPath); + const command = isJs ? defaultExecutable() : binaryPath; + const commandArgs = isJs + ? [...executableArgs, binaryPath, ...flags] + : [...executableArgs, ...flags]; + + const env = { ...(o.env ?? process.env) }; + // Match the SDK: tag the entrypoint and never leak NODE_OPTIONS into the + // child (it can crash the bundled CLI). + if (!env.CLAUDE_CODE_ENTRYPOINT) env.CLAUDE_CODE_ENTRYPOINT = "sdk-ts"; + delete env.NODE_OPTIONS; + + const child: ChildProcessWithoutNullStreams = spawn(command, commandArgs, { + cwd: o.cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + const messages = makeAsyncQueue(); + const pendingControl = new Map< + string, + { resolve: (r: ControlResponseEnvelope["response"]) => void; reject: (e: unknown) => void } + >(); + const cancelControllers = new Map(); + let closed = false; + let exited = false; + let killTimer: NodeJS.Timeout | undefined; + + const writeLine = (obj: unknown): void => { + if (closed || exited || !child.stdin.writable) return; + try { + child.stdin.write(`${JSON.stringify(obj)}\n`); + } catch { + /* stdin closed underneath us — surfaced via the exit handler */ + } + }; + + // ── stdout NDJSON framing ──────────────────────────────────────────── + let stdoutBuffer = ""; + const handleParsed = (parsed: unknown): void => { + if (!parsed || typeof parsed !== "object") return; + const record = parsed as { type?: unknown }; + + if (record.type === "control_response") { + const env_ = parsed as ControlResponseEnvelope; + const pending = pendingControl.get(env_.response.request_id); + if (pending) { + pendingControl.delete(env_.response.request_id); + if (env_.response.subtype === "success") pending.resolve(env_.response); + else pending.reject(new Error(env_.response.error ?? "control request failed")); + } + return; + } + + if (record.type === "control_request") { + void handleInboundControlRequest(parsed as ControlRequestEnvelope); + return; + } + + if (record.type === "control_cancel_request") { + const reqId = (parsed as { request_id?: string }).request_id; + if (reqId) { + cancelControllers.get(reqId)?.abort(); + cancelControllers.delete(reqId); + } + return; + } + + if (record.type === "keep_alive" || record.type === "transcript_mirror") { + return; + } + + messages.push(parsed as SDKMessage); + }; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdoutBuffer += chunk; + let newlineIndex = stdoutBuffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (line.length > 0) { + try { + handleParsed(JSON.parse(line)); + } catch { + /* tolerate a malformed line rather than tearing down the stream */ + } + } + newlineIndex = stdoutBuffer.indexOf("\n"); + } + }); + + let stderrTail = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderrTail = `${stderrTail}${chunk}`.slice(-4_000); + }); + + // ── inbound can_use_tool → canUseTool callback ─────────────────────── + const handleInboundControlRequest = async (req: ControlRequestEnvelope): Promise => { + if (req.request.subtype !== "can_use_tool") { + // Unknown inbound control request: acknowledge with an error so the CLI + // does not hang waiting for a response. + writeLine({ + type: "control_response", + response: { + subtype: "error", + request_id: req.request_id, + error: `Unsupported control request subtype: ${req.request.subtype}`, + }, + }); + return; + } + + const callback = o.canUseTool; + if (typeof callback !== "function") { + writeLine({ + type: "control_response", + response: { + subtype: "error", + request_id: req.request_id, + error: "canUseTool callback is not provided.", + }, + }); + return; + } + + const controller = new AbortController(); + cancelControllers.set(req.request_id, controller); + try { + const result: PermissionResult = await callback( + req.request.tool_name ?? "", + req.request.input ?? {}, + { + signal: controller.signal, + suggestions: req.request.permission_suggestions, + blockedPath: req.request.blocked_path, + decisionReason: req.request.decision_reason, + title: req.request.title, + } as unknown as Parameters[2], + ); + writeLine({ + type: "control_response", + response: { subtype: "success", request_id: req.request_id, response: result }, + }); + } catch (error) { + writeLine({ + type: "control_response", + response: { + subtype: "error", + request_id: req.request_id, + error: error instanceof Error ? error.message : String(error), + }, + }); + } finally { + cancelControllers.delete(req.request_id); + } + }; + + // ── outbound control request (interrupt / set_* / etc.) ─────────────── + const sendControlRequest = (request: Record): Promise => { + if (closed || exited) { + return Promise.reject(new Error("Claude CLI session is closed.")); + } + const requestId = newRequestId(); + return new Promise((resolve, reject) => { + pendingControl.set(requestId, { + resolve: (r) => resolve(r.response), + reject, + }); + try { + writeLine({ request_id: requestId, type: "control_request", request }); + } catch (error) { + pendingControl.delete(requestId); + reject(error); + } + }); + }; + + // ── initialize handshake ───────────────────────────────────────────── + // Sent synchronously (before the async prompt pump's first write) so the + // CLI is configured before any user message is processed, matching the + // SDK which always runs `this.initialization = this.initialize()`. + const initialization = sendControlRequest( + buildInitializePayload(options), + ) as Promise; + // Prevent an unhandled rejection when no one awaits the handshake (the + // conversational path ignores it; only the probe consumes the result). + initialization.catch(() => {}); + + // ── prompt pump: user messages → stdin NDJSON ───────────────────────── + void (async () => { + try { + for await (const message of input.prompt) { + if (closed || exited) break; + writeLine(message); + } + } catch { + /* prompt iterable ended via interruption — normal shutdown path */ + } finally { + if (!closed && !exited && child.stdin.writable) { + try { + child.stdin.end(); + } catch { + /* already ended */ + } + } + } + })(); + + // ── lifecycle ───────────────────────────────────────────────────────── + child.on("error", (error: Error) => { + exited = true; + for (const pending of pendingControl.values()) pending.reject(error); + pendingControl.clear(); + messages.fail(error); + }); + + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + exited = true; + if (killTimer) clearTimeout(killTimer); + for (const pending of pendingControl.values()) { + pending.reject(new Error("Claude CLI session closed.")); + } + pendingControl.clear(); + if (closed || code === 0 || code === null) { + messages.end(); + } else { + const detail = stderrTail.trim(); + messages.fail( + new Error( + `Claude CLI exited with code ${code}${signal ? ` (signal ${signal})` : ""}${ + detail ? `: ${detail}` : "" + }`, + ), + ); + } + }); + + const close = (): void => { + if (closed) return; + closed = true; + try { + if (child.stdin.writable) child.stdin.end(); + } catch { + /* already ended */ + } + killTimer = setTimeout(() => { + if (exited) return; + child.kill("SIGTERM"); + setTimeout(() => { + if (!exited) child.kill("SIGKILL"); + }, SIGKILL_ESCALATION_MS).unref?.(); + }, GRACEFUL_SHUTDOWN_MS); + killTimer.unref?.(); + }; + + return { + [Symbol.asyncIterator]: () => messages.iterator(), + interrupt: async () => { + await sendControlRequest({ subtype: "interrupt" }); + }, + setModel: async (model?: string) => { + await sendControlRequest({ subtype: "set_model", model }); + }, + setPermissionMode: async (mode: string) => { + await sendControlRequest({ subtype: "set_permission_mode", mode }); + }, + setMaxThinkingTokens: async (maxThinkingTokens: number | null) => { + await sendControlRequest({ + subtype: "set_max_thinking_tokens", + max_thinking_tokens: maxThinkingTokens, + }); + }, + initialize: () => initialization, + close, + }; +} diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d00be86d3e4..f99f6995378 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,101 +1,203 @@ -import type { - ClaudeSettings, - ModelCapabilities, - ServerProvider, - ServerProviderModel, - ServerProviderAuth, - ServerProviderSlashCommand, - ServerProviderState, +import { + type ClaudeSettings, + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, + type ServerProviderModel, + type ServerProviderSlashCommand, } from "@t3tools/contracts"; -import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { - query as claudeQuery, - type SlashCommand as ClaudeSlashCommand, + createModelCapabilities, + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import type { + SlashCommand as ClaudeSlashCommand, + SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import { makeClaudeCliQuery } from "./ClaudeCliTransport.ts"; + import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, detailFromResult, - extractAuthBoolean, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, - type CommandResult, -} from "../providerSnapshot"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import { ServerSettingsService } from "../../serverSettings"; -import { ServerSettingsError } from "@t3tools/contracts"; - -const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; -const PROVIDER = "claudeAgent" as const; +const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const PROVIDER = ProviderDriverKind.make("claudeAgent"); +const CLAUDE_PRESENTATION = { + displayName: "Claude", + showInteractionModeToggle: true, +} as const; +const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), + ], + }), + }, { slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + }), + }, + { + slug: "claude-opus-4-5", + name: "Claude Opus 4.5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + ], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), ], - promptInjectedEffortLevels: ["ultrathink"], - } satisfies ModelCapabilities, + }), }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } satisfies ModelCapabilities, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildBooleanOptionDescriptor({ + id: "thinking", + label: "Thinking", + }), + ], + }), }, ]; +function supportsClaudeOpus47(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; +} + +function getBuiltInClaudeModelsForVersion( + version: string | null | undefined, +): ReadonlyArray { + if (supportsClaudeOpus47(version)) { + return BUILT_IN_MODELS; + } + return BUILT_IN_MODELS.filter((model) => model.slug !== "claude-opus-4-7"); +} + +function formatClaudeOpus47UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; +} + export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( @@ -104,180 +206,46 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo ); } -export function parseClaudeAuthStatusFromOutput(result: CommandResult): { - readonly status: Exclude; - readonly auth: Pick; - readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { - return { - status: "warning", - auth: { status: "unknown" }, - message: - "Claude Agent authentication status command is unavailable in this version of Claude.", - }; - } - - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `claude login`") || - lowerOutput.includes("run claude login") - ) { - return { - status: "error", - auth: { status: "unauthenticated" }, - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } - - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", auth: { status: "authenticated" } }; - } - if (parsedAuth.auth === false) { - return { - status: "error", - auth: { status: "unauthenticated" }, - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - auth: { status: "unknown" }, - message: - "Could not verify Claude authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", auth: { status: "authenticated" } }; - } - - const detail = detailFromResult(result); - return { - status: "warning", - auth: { status: "unknown" }, - message: detail - ? `Could not verify Claude authentication status. ${detail}` - : "Could not verify Claude authentication status.", - }; +export function resolveClaudeEffort( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); + const value = getProviderOptionCurrentValue(effortDescriptor); + return typeof value === "string" ? value : undefined; } -// ── Subscription type detection ───────────────────────────────────── -// -// The SDK probe returns typed `AccountInfo.subscriptionType` directly. -// This walker is a best-effort fallback for the `claude auth status` -// JSON output whose shape is not guaranteed. - -/** Keys that directly hold a subscription/plan identifier. */ -const SUBSCRIPTION_TYPE_KEYS = [ - "subscriptionType", - "subscription_type", - "plan", - "tier", - "planType", - "plan_type", -] as const; - -/** Keys whose value may be a nested object containing subscription info. */ -const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const; -const AUTH_METHOD_KEYS = ["authMethod", "auth_method"] as const; -const AUTH_METHOD_CONTAINER_KEYS = ["auth", "account", "session"] as const; - -/** Lift an unknown value into `Option` if it is a non-empty string. */ -const asNonEmptyString = (v: unknown): Option.Option => - typeof v === "string" && v.length > 0 ? Option.some(v) : Option.none(); - -/** Lift an unknown value into `Option` if it is a plain object. */ -const asRecord = (v: unknown): Option.Option> => - typeof v === "object" && v !== null && !globalThis.Array.isArray(v) - ? Option.some(v as Record) - : Option.none(); - /** - * Walk an unknown parsed JSON value looking for a subscription/plan - * identifier, returning the first match as an `Option`. + * Normalize a resolved Claude effort value into one suitable for the Claude + * CLI's `--effort` flag. + * + * Mirrors the mapping used when invoking the Claude Agent SDK + * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): the Opus 4.7 + * capability `"xhigh"` is rewritten to the accepted CLI value `"max"`, and + * `"ultrathink"` is filtered out because it is a prompt-prefix mode rather + * than a CLI-effort value. Returns `undefined` when no flag should be passed. */ -function findSubscriptionType(value: unknown): Option.Option { - if (globalThis.Array.isArray(value)) { - return Option.firstSomeOf(value.map(findSubscriptionType)); +export function normalizeClaudeCliEffort(effort: string | null | undefined): string | undefined { + if (!effort || effort === "ultrathink") { + return undefined; } - - return asRecord(value).pipe( - Option.flatMap((record) => { - const direct = Option.firstSomeOf( - SUBSCRIPTION_TYPE_KEYS.map((key) => asNonEmptyString(record[key])), - ); - if (Option.isSome(direct)) return direct; - - return Option.firstSomeOf( - SUBSCRIPTION_CONTAINER_KEYS.map((key) => - asRecord(record[key]).pipe(Option.flatMap(findSubscriptionType)), - ), - ); - }), - ); -} - -function findAuthMethod(value: unknown): Option.Option { - if (globalThis.Array.isArray(value)) { - return Option.firstSomeOf(value.map(findAuthMethod)); + if (effort === "xhigh") { + return "max"; } - - return asRecord(value).pipe( - Option.flatMap((record) => { - const direct = Option.firstSomeOf( - AUTH_METHOD_KEYS.map((key) => asNonEmptyString(record[key])), - ); - if (Option.isSome(direct)) return direct; - - return Option.firstSomeOf( - AUTH_METHOD_CONTAINER_KEYS.map((key) => - asRecord(record[key]).pipe(Option.flatMap(findAuthMethod)), - ), - ); - }), - ); -} - -/** - * Try to extract a subscription type from the `claude auth status` JSON - * output. This is a zero-cost operation on data we already have. - */ -const decodeUnknownJson = decodeJsonResult(Schema.Unknown); - -function extractSubscriptionTypeFromOutput(result: CommandResult): string | undefined { - const parsed = decodeUnknownJson(result.stdout.trim()); - if (Result.isFailure(parsed)) return undefined; - return Option.getOrUndefined(findSubscriptionType(parsed.success)); + return effort; } -function extractClaudeAuthMethodFromOutput(result: CommandResult): string | undefined { - const parsed = decodeUnknownJson(result.stdout.trim()); - if (Result.isFailure(parsed)) return undefined; - return Option.getOrUndefined(findAuthMethod(parsed.success)); +export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { + switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } } function toTitleCaseWords(value: string): string { @@ -293,11 +261,27 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | if (!normalized) return undefined; switch (normalized) { + case "claudemaxsubscription": + return "Max"; + case "claudemax5xsubscription": + return "Max 5x"; + case "claudemax20xsubscription": + return "Max 20x"; + case "claudeenterprisesubscription": + return "Enterprise"; + case "claudeteamsubscription": + return "Team"; + case "claudeprosubscription": + return "Pro"; + case "claudefreesubscription": + return "Free"; case "max": case "maxplan": + return "Max"; case "max5": + return "Max 5x"; case "max20": - return "Max"; + return "Max 20x"; case "enterprise": return "Enterprise"; case "team": @@ -314,10 +298,33 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); if (!normalized) return undefined; - if (normalized === "apikey") return "apiKey"; + if ( + normalized === "apikey" || + normalized === "anthropicapikey" || + normalized === "anthropicauthtoken" + ) { + return "apiKey"; + } return undefined; } +function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { + const subscriptionLabel = + claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType); + const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, ""); + + if (normalized.startsWith("claude") && normalized.endsWith("subscription")) { + return subscriptionLabel; + } + if (normalized.startsWith("claude")) { + return `${subscriptionLabel} Subscription`; + } + if (normalized.endsWith("subscription")) { + return `Claude ${subscriptionLabel}`; + } + return `Claude ${subscriptionLabel} Subscription`; +} + function claudeAuthMetadata(input: { readonly subscriptionType: string | undefined; readonly authMethod: string | undefined; @@ -330,10 +337,9 @@ function claudeAuthMetadata(input: { } if (input.subscriptionType) { - const subscriptionLabel = claudeSubscriptionLabel(input.subscriptionType); return { type: input.subscriptionType, - label: `Claude ${subscriptionLabel ?? toTitleCaseWords(input.subscriptionType)} Subscription`, + label: formatClaudeSubscriptionAuthLabel(input.subscriptionType), }; } @@ -349,6 +355,13 @@ function nonEmptyProbeString(value: string): string | undefined { return candidate ? candidate : undefined; } +type ClaudeCapabilitiesProbe = { + readonly email: string | undefined; + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + readonly slashCommands: ReadonlyArray; +}; + function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { @@ -412,37 +425,66 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } +function waitForAbortSignal(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. * - * The prompt is never sent to the Anthropic API — we abort immediately - * after the local initialization phase completes. This gives us the - * user's subscription type without incurring any token cost. + * We pass a never-yielding AsyncIterable as the prompt so that no user + * message is ever written to the subprocess stdin. This means the Claude + * Code subprocess completes its local initialization IPC (returning + * account info and slash commands) but never starts an API request to + * Anthropic. We read the init data and then abort the subprocess. * * This is used as a fallback when `claude auth status` does not include * subscription type information. */ -const probeClaudeCapabilities = (binaryPath: string) => { +const probeClaudeCapabilities = ( + claudeSettings: ClaudeSettings, + environment: NodeJS.ProcessEnv = process.env, +) => { const abort = new AbortController(); - return Effect.tryPromise(async () => { - const q = claudeQuery({ - prompt: ".", - options: { - persistSession: false, - pathToClaudeCodeExecutable: binaryPath, - abortController: abort, - maxTurns: 0, - settingSources: ["user", "project", "local"], - allowedTools: [], - stderr: () => {}, - }, + return Effect.gen(function* () { + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + return yield* Effect.tryPromise(async () => { + const q = makeClaudeCliQuery({ + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), + options: { + persistSession: false, + pathToClaudeCodeExecutable: claudeSettings.binaryPath, + settingSources: ["user", "project", "local"], + allowedTools: [], + env: claudeEnvironment, + } as unknown as Parameters[0]["options"], + }); + try { + const init = await q.initialize(); + const account = init.account; + return { + email: account?.email, + subscriptionType: account?.subscriptionType, + tokenSource: account?.tokenSource, + slashCommands: parseClaudeInitializationCommands( + init.commands as ReadonlyArray | undefined, + ), + } satisfies ClaudeCapabilitiesProbe; + } finally { + q.close(); + } }); - const init = await q.initializationResult(); - return { - subscriptionType: init.account?.subscriptionType, - slashCommands: parseClaudeInitializationCommands(init.commands), - }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -458,33 +500,32 @@ const probeClaudeCapabilities = (binaryPath: string) => { ); }; -const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (args: ReadonlyArray) { - const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.claudeAgent), - ); +const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( + claudeSettings: ClaudeSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) { + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { + env: claudeEnvironment, shell: process.platform === "win32", }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( - resolveSubscriptionType?: (binaryPath: string) => Effect.Effect, - resolveSlashCommands?: ( - binaryPath: string, - ) => Effect.Effect | undefined>, + claudeSettings: ClaudeSettings, + resolveCapabilities?: ( + claudeSettings: ClaudeSettings, + ) => Effect.Effect, + environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return< - ServerProvider, - ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Path.Path > { - const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.claudeAgent), - ); - const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings( + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const allModels = providerModelsFromSettings( BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels, @@ -493,10 +534,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (!claudeSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: false, checkedAt, - models, + models: allModels, probe: { installed: false, version: null, @@ -507,7 +548,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -515,10 +556,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; return buildServerProvider({ - provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: !isCommandMissingCause(error), version: null, @@ -533,10 +574,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Option.isNone(versionProbe.success)) { return buildServerProvider({ - provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: null, @@ -553,10 +594,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (version.code !== 0) { const detail = detailFromResult(version); return buildServerProvider({ - provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: parsedVersion, @@ -569,65 +610,25 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const slashCommands = - (resolveSlashCommands - ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( - Effect.orElseSucceed(() => undefined), - ) - : undefined) ?? []; - const dedupedSlashCommands = dedupeSlashCommands(slashCommands); - - // ── Auth check + subscription detection ──────────────────────────── - - const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, + const models = providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(parsedVersion), + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); + const opus47UpgradeMessage = supportsClaudeOpus47(parsedVersion) + ? undefined + : formatClaudeOpus47UpgradeMessage(parsedVersion); + + const capabilities = resolveCapabilities + ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + const slashCommands = capabilities?.slashCommands ?? []; + const dedupedSlashCommands = dedupeSlashCommands(slashCommands); - // Determine subscription type from multiple sources (cheapest first): - // 1. `claude auth status` JSON output (may or may not contain it) - // 2. Cached SDK probe (spawns a Claude process on miss, reads - // `initializationResult()` for account metadata, then aborts - // immediately — no API tokens are consumed) - - let subscriptionType: string | undefined; - let authMethod: string | undefined; - - if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { - subscriptionType = extractSubscriptionTypeFromOutput(authProbe.success.value); - authMethod = extractClaudeAuthMethodFromOutput(authProbe.success.value); - } - - if (!subscriptionType && resolveSubscriptionType) { - subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); - } - - // ── Handle auth results (same logic as before, adjusted models) ── - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, - slashCommands: dedupedSlashCommands, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - auth: { status: "unknown" }, - message: - error instanceof Error - ? `Could not verify Claude authentication status: ${error.message}.` - : "Could not verify Claude authentication status.", - }, - }); - } - - if (Option.isNone(authProbe.success)) { + if (!capabilities) { return buildServerProvider({ - provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -637,15 +638,17 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "warning", auth: { status: "unknown" }, - message: "Could not verify Claude authentication status. Timed out while running command.", + message: "Could not verify Claude authentication status from initialization result.", }, }); } - const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); - const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); + const authMetadata = claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + }); return buildServerProvider({ - provider: PROVIDER, + presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, @@ -653,52 +656,60 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( probe: { installed: true, version: parsedVersion, - status: parsed.status, + status: "ready", auth: { - ...parsed.auth, + status: "authenticated", + ...(capabilities.email ? { email: capabilities.email } : {}), ...(authMetadata ? authMetadata : {}), }, - ...(parsed.message ? { message: parsed.message } : {}), + ...(opus47UpgradeMessage ? { message: opus47UpgradeMessage } : {}), }, }); }); -export const ClaudeProviderLive = Layer.effect( - ClaudeProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const subscriptionProbeCache = yield* Cache.make({ - capacity: 1, - timeToLive: Duration.minutes(5), - lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), - }); - - const checkProvider = checkClaudeProviderStatus( - (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( - Effect.map((probe) => probe?.subscriptionType), - ), - (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( - Effect.map((probe) => probe?.slashCommands), - ), - ).pipe( - Effect.provideService(ServerSettingsService, serverSettings), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), +export const makePendingClaudeProvider = ( + claudeSettings: ClaudeSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* nowIso; + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - return yield* makeManagedServerProvider({ - getSettings: serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.claudeAgent), - Effect.orDie, - ), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.claudeAgent), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - checkProvider, + if (!claudeSettings.enabled) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude provider status has not been checked in this session yet.", + }, }); - }), -); + }); + +export { probeClaudeCapabilities }; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c4ee33b7768..c5eaa536c3f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1,7 +1,14 @@ +// @effect-diagnostics nodeBuiltinImport:off import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { ApprovalRequestId, + CodexSettings, EventId, + ProviderDriverKind, + ProviderInstanceId, ProviderItemId, type ProviderApprovalDecision, type ProviderEvent, @@ -11,146 +18,219 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { afterAll, it, vi } from "@effect/vitest"; +import { it, vi } from "@effect/vitest"; + +import * as Context from "effect/Context"; +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 Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as CodexErrors from "effect-codex-app-server/errors"; -import { Effect, Fiber, Layer, Option, Stream } from "effect"; - -import { - CodexAppServerManager, - type CodexAppServerStartSessionInput, - type CodexAppServerSendTurnInput, -} from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import { makeCodexAdapterLive } from "./CodexAdapter.ts"; +import { + type CodexSessionRuntimeOptions, + type CodexSessionRuntimeSendTurnInput, + type CodexSessionRuntimeShape, + type CodexThreadSnapshot, +} from "./CodexSessionRuntime.ts"; +import { makeCodexAdapter } from "./CodexAdapter.ts"; +const decodeCodexSettings = Schema.decodeSync(CodexSettings); + +// Test-local service tag so the rest of the file can keep using `yield* CodexAdapter`. +class CodexAdapter extends Context.Service()( + "test/CodexAdapter", +) {} const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.make(value); -class FakeCodexManager extends CodexAppServerManager { - public startSessionImpl = vi.fn( - async (input: CodexAppServerStartSessionInput): Promise => { - const now = new Date().toISOString(); - return { - provider: "codex", - status: "ready", - runtimeMode: input.runtimeMode, - threadId: input.threadId, - cwd: input.cwd, - createdAt: now, - updatedAt: now, - }; - }, +class FakeCodexRuntime implements CodexSessionRuntimeShape { + private readonly eventQueue = Effect.runSync(Queue.unbounded()); + private readonly now = "2026-01-01T00:00:00.000Z"; + + public readonly startImpl = vi.fn(() => + Promise.resolve({ + provider: ProviderDriverKind.make("codex"), + status: "ready" as const, + runtimeMode: this.options.runtimeMode, + threadId: this.options.threadId, + cwd: this.options.cwd, + ...(this.options.model ? { model: this.options.model } : {}), + createdAt: this.now, + updatedAt: this.now, + } satisfies ProviderSession), ); - public sendTurnImpl = vi.fn( - async (_input: CodexAppServerSendTurnInput): Promise => ({ - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-1"), - }), + public readonly sendTurnImpl = vi.fn( + (_input: CodexSessionRuntimeSendTurnInput): Promise => + Promise.resolve({ + threadId: this.options.threadId, + turnId: asTurnId("turn-1"), + }), ); - public interruptTurnImpl = vi.fn( - async (_threadId: ThreadId, _turnId?: TurnId): Promise => undefined, + public readonly interruptTurnImpl = vi.fn( + (_turnId?: TurnId): Promise => Promise.resolve(undefined), ); - public readThreadImpl = vi.fn(async (_threadId: ThreadId) => ({ - threadId: asThreadId("thread-1"), - turns: [], - })); - - public rollbackThreadImpl = vi.fn(async (_threadId: ThreadId, _numTurns: number) => ({ - threadId: asThreadId("thread-1"), - turns: [], - })); - - public respondToRequestImpl = vi.fn( - async ( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _decision: ProviderApprovalDecision, - ): Promise => undefined, + public readonly readThreadImpl = vi.fn( + (): Promise => + Promise.resolve({ + threadId: "provider-thread-1", + turns: [], + }), ); - public respondToUserInputImpl = vi.fn( - async ( - _threadId: ThreadId, - _requestId: ApprovalRequestId, - _answers: ProviderUserInputAnswers, - ): Promise => undefined, + public readonly rollbackThreadImpl = vi.fn( + (_numTurns: number): Promise => + Promise.resolve({ + threadId: "provider-thread-1", + turns: [], + }), ); - public stopAllImpl = vi.fn(() => undefined); + public readonly respondToRequestImpl = vi.fn( + (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise => + Promise.resolve(undefined), + ); - override startSession(input: CodexAppServerStartSessionInput): Promise { - return this.startSessionImpl(input); - } + public readonly respondToUserInputImpl = vi.fn( + (_requestId: ApprovalRequestId, _answers: ProviderUserInputAnswers): Promise => + Promise.resolve(undefined), + ); - override sendTurn(input: CodexAppServerSendTurnInput): Promise { - return this.sendTurnImpl(input); - } + public readonly closeImpl = vi.fn(() => Promise.resolve(undefined)); - override interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise { - return this.interruptTurnImpl(threadId, turnId); + readonly options: CodexSessionRuntimeOptions; + + constructor(options: CodexSessionRuntimeOptions) { + this.options = options; } - override readThread(threadId: ThreadId) { - return this.readThreadImpl(threadId); + start() { + return Effect.promise(() => this.startImpl()); } - override rollbackThread(threadId: ThreadId, numTurns: number) { - return this.rollbackThreadImpl(threadId, numTurns); + getSession = Effect.promise(() => this.startImpl()); + + sendTurn(input: CodexSessionRuntimeSendTurnInput) { + return Effect.promise(() => this.sendTurnImpl(input)); } - override respondToRequest( - threadId: ThreadId, - requestId: ApprovalRequestId, - decision: ProviderApprovalDecision, - ): Promise { - return this.respondToRequestImpl(threadId, requestId, decision); + interruptTurn(turnId?: TurnId) { + return Effect.promise(() => this.interruptTurnImpl(turnId)); } - override respondToUserInput( - threadId: ThreadId, - requestId: ApprovalRequestId, - answers: ProviderUserInputAnswers, - ): Promise { - return this.respondToUserInputImpl(threadId, requestId, answers); + readThread = Effect.promise(() => this.readThreadImpl()); + + rollbackThread(numTurns: number) { + return Effect.promise(() => this.rollbackThreadImpl(numTurns)); } - override stopSession(_threadId: ThreadId): void {} + respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { + return Effect.promise(() => this.respondToRequestImpl(requestId, decision)); + } - override listSessions(): ProviderSession[] { - return []; + respondToUserInput(requestId: ApprovalRequestId, answers: ProviderUserInputAnswers) { + return Effect.promise(() => this.respondToUserInputImpl(requestId, answers)); } - override hasSession(_threadId: ThreadId): boolean { - return false; + get events() { + return Stream.fromQueue(this.eventQueue); } - override stopAll(): void { - this.stopAllImpl(); + close = Effect.promise(() => this.closeImpl()); + + emit(event: ProviderEvent) { + return Queue.offer(this.eventQueue, event).pipe(Effect.asVoid); } } +function makeRuntimeFactory() { + const runtimes: Array = []; + const factory = vi.fn((options: CodexSessionRuntimeOptions) => { + const runtime = new FakeCodexRuntime(options); + runtimes.push(runtime); + return Effect.succeed(runtime); + }); + + return { + factory, + get lastRuntime(): FakeCodexRuntime | undefined { + return runtimes.at(-1); + }, + }; +} + +function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolean }) { + const runtimes: Array = []; + const releasedThreadIds: Array = []; + + const factory = vi.fn((runtimeOptions: CodexSessionRuntimeOptions) => + Effect.gen(function* () { + yield* Scope.Scope; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + releasedThreadIds.push(runtimeOptions.threadId); + }), + ); + + if (options?.failConstruction) { + return yield* new CodexErrors.CodexAppServerSpawnError({ + command: `${runtimeOptions.binaryPath} app-server`, + cause: new Error("runtime construction failed"), + }); + } + + const runtime = new FakeCodexRuntime(runtimeOptions); + runtimes.push(runtime); + return runtime; + }), + ); + + return { + factory, + releasedThreadIds, + get lastRuntime(): FakeCodexRuntime | undefined { + return runtimes.at(-1); + }, + }; +} + const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), - remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), }); -const validationManager = new FakeCodexManager(); +const validationRuntimeFactory = makeRuntimeFactory(); const validationLayer = it.layer( - makeCodexAdapterLive({ manager: validationManager }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: validationRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -164,7 +244,7 @@ validationLayer("CodexAdapterLive validation", (it) => { const adapter = yield* CodexAdapter; const result = yield* adapter .startSession({ - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), threadId: asThreadId("thread-1"), runtimeMode: "full-access", }) @@ -174,50 +254,52 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.deepStrictEqual( result.failure, new ProviderAdapterValidationError({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), operation: "startSession", issue: "Expected provider 'codex' but received 'claudeAgent'.", }), ); - assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); + assert.equal(validationRuntimeFactory.factory.mock.calls.length, 0); }), ); it.effect("maps codex model options before starting a session", () => Effect.gen(function* () { - validationManager.startSessionImpl.mockClear(); + validationRuntimeFactory.factory.mockClear(); const adapter = yield* CodexAdapter; yield* adapter.startSession({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "fastMode", value: true }, + ]), runtimeMode: "full-access", }); - assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { - provider: "codex", - threadId: asThreadId("thread-1"), + assert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { binaryPath: "codex", + cwd: process.cwd(), model: "gpt-5.3-codex", + providerInstanceId: ProviderInstanceId.make("codex"), serviceTier: "fast", + threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); }), ); }); -const sessionErrorManager = new FakeCodexManager(); -sessionErrorManager.sendTurnImpl.mockImplementation(async () => { - throw new Error("Unknown session: sess-missing"); -}); +const sessionRuntimeFactory = makeRuntimeFactory(); const sessionErrorLayer = it.layer( - makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: sessionRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -226,7 +308,7 @@ const sessionErrorLayer = it.layer( ); sessionErrorLayer("CodexAdapterLive session errors", (it) => { - it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => + it.effect("maps missing adapter sessions to ProviderAdapterSessionNotFoundError", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; const result = yield* adapter @@ -241,33 +323,34 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); assert.equal(result.failure.provider, "codex"); assert.equal(result.failure.threadId, "sess-missing"); - assert.equal(result.failure.cause instanceof Error, true); }), ); it.effect("maps codex model options before sending a turn", () => Effect.gen(function* () { - sessionErrorManager.sendTurnImpl.mockClear(); const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-missing"), + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + assert.ok(runtime); + runtime.sendTurnImpl.mockClear(); yield* Effect.ignore( adapter.sendTurn({ threadId: asThreadId("sess-missing"), input: "hello", - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "high", - fastMode: true, - }, - }, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]), attachments: [], }), ); - assert.deepStrictEqual(sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], { - threadId: asThreadId("sess-missing"), + assert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { input: "hello", model: "gpt-5.3-codex", effort: "high", @@ -275,11 +358,74 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }); }), ); + + it.effect("maps codex model options for the adapter's bound custom instance id", () => { + const customInstanceId = ProviderInstanceId.make("codex_personal"); + const customRuntimeFactory = makeRuntimeFactory(); + const customLayer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + instanceId: customInstanceId, + makeRuntime: customRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-custom-instance"), + runtimeMode: "full-access", + }); + const runtime = customRuntimeFactory.lastRuntime; + assert.ok(runtime); + runtime.sendTurnImpl.mockClear(); + + yield* Effect.ignore( + adapter.sendTurn({ + threadId: asThreadId("sess-custom-instance"), + input: "hello", + modelSelection: createModelSelection( + ProviderInstanceId.make("codex_personal"), + "gpt-5.3-codex", + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ), + attachments: [], + }), + ); + + assert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { + input: "hello", + model: "gpt-5.3-codex", + effort: "high", + serviceTier: "fast", + }); + }).pipe(Effect.provide(customLayer)); + }); }); -const lifecycleManager = new FakeCodexManager(); +const lifecycleRuntimeFactory = makeRuntimeFactory(); const lifecycleLayer = it.layer( - makeCodexAdapterLive({ manager: lifecycleManager }).pipe( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: lifecycleRuntimeFactory.factory, + }); + }), + ).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), @@ -287,30 +433,48 @@ const lifecycleLayer = it.layer( ), ); +function startLifecycleRuntime() { + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }); + const runtime = lifecycleRuntimeFactory.lastRuntime; + assert.ok(runtime); + return { adapter, runtime }; + }); +} + lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event: ProviderEvent = { id: asEventId("evt-msg-complete"), kind: "notification", - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", method: "item/completed", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), itemId: asItemId("msg_1"), payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", item: { type: "agentMessage", id: "msg_1", + text: "done", }, }, }; - lifecycleManager.emit("event", event); + yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); assert.equal(firstEvent._tag, "Some"); @@ -329,28 +493,31 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps completed plan items to canonical proposed-plan completion events", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event: ProviderEvent = { id: asEventId("evt-plan-complete"), kind: "notification", - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", method: "item/completed", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), itemId: asItemId("plan_1"), payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", item: { - type: "Plan", + type: "plan", id: "plan_1", text: "## Final plan\n\n- one\n- two", }, }, }; - lifecycleManager.emit("event", event); + yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); assert.equal(firstEvent._tag, "Some"); @@ -368,19 +535,22 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps plan deltas to canonical proposed-plan delta events", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - lifecycleManager.emit("event", { + yield* runtime.emit({ id: asEventId("evt-plan-delta"), kind: "notification", - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", method: "item/plan/delta", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), itemId: asItemId("plan_1"), payload: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "plan_1", delta: "## Final plan", }, } satisfies ProviderEvent); @@ -402,20 +572,20 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event: ProviderEvent = { id: asEventId("evt-session-closed"), kind: "session", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "session/closed", message: "Session stopped", }; - lifecycleManager.emit("event", event); + yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); assert.equal(firstEvent._tag, "Some"); @@ -433,18 +603,20 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps retryable Codex error notifications to runtime.warning", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - lifecycleManager.emit("event", { + yield* runtime.emit({ id: asEventId("evt-retryable-error"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "error", turnId: asTurnId("turn-1"), payload: { + threadId: "thread-1", + turnId: "turn-1", error: { message: "Reconnecting... 2/5", }, @@ -469,15 +641,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps process stderr notifications to runtime.warning", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - lifecycleManager.emit("event", { + yield* runtime.emit({ id: asEventId("evt-process-stderr"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "process/stderr", turnId: asTurnId("turn-1"), message: "The filename or extension is too long. (os error 206)", @@ -501,17 +673,51 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps realtime started notifications with upstream realtime session ids", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-realtime-started"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "thread/realtime/started", + payload: { + threadId: "thread-1", + realtimeSessionId: "realtime-session-1", + version: "v2", + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "thread.realtime.started"); + if (firstEvent.value.type !== "thread.realtime.started") { + return; + } + assert.equal(firstEvent.value.threadId, "thread-1"); + assert.equal(firstEvent.value.payload.realtimeSessionId, "realtime-session-1"); + }), + ); + it.effect("maps fatal websocket stderr notifications to runtime.error", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - lifecycleManager.emit("event", { + yield* runtime.emit({ id: asEventId("evt-process-stderr-websocket"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "process/stderr", turnId: asTurnId("turn-1"), message: @@ -539,26 +745,25 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("preserves request type when mapping serverRequest/resolved", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event: ProviderEvent = { id: asEventId("evt-request-resolved"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "serverRequest/resolved", + requestKind: "command", requestId: ApprovalRequestId.make("req-1"), payload: { - request: { - method: "item/commandExecution/requestApproval", - }, - decision: "accept", + threadId: "thread-1", + requestId: "req-1", }, }; - lifecycleManager.emit("event", event); + yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); assert.equal(firstEvent._tag, "Some"); @@ -575,26 +780,25 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("preserves file-read request type when mapping serverRequest/resolved", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event: ProviderEvent = { id: asEventId("evt-file-read-request-resolved"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "serverRequest/resolved", + requestKind: "file-read", requestId: ApprovalRequestId.make("req-file-read-1"), payload: { - request: { - method: "item/fileRead/requestApproval", - }, - decision: "accept", + threadId: "thread-1", + requestId: "req-file-read-1", }, }; - lifecycleManager.emit("event", event); + yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); assert.equal(firstEvent._tag, "Some"); @@ -611,24 +815,26 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("preserves explicit empty multi-select user-input answers", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event: ProviderEvent = { id: asEventId("evt-user-input-empty"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "item/tool/requestUserInput/answered", payload: { answers: { - scope: [], + scope: { + answers: [], + }, }, }, }; - lifecycleManager.emit("event", event); + yield* runtime.emit(event); const firstEvent = yield* Fiber.join(firstEventFiber); assert.equal(firstEvent._tag, "Some"); @@ -647,7 +853,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps windowsSandbox/setupCompleted to session state and warning on failure", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( Effect.forkChild, ); @@ -655,18 +861,19 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const event: ProviderEvent = { id: asEventId("evt-windows-sandbox-failed"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "windowsSandbox/setupCompleted", message: "Sandbox setup failed", payload: { + mode: "unelevated", success: false, - detail: "unsupported environment", + error: "unsupported environment", }, }; - lifecycleManager.emit("event", event); + yield* runtime.emit(event); const events = Array.from(yield* Fiber.join(eventsFiber)); assert.equal(events.length, 2); @@ -691,20 +898,23 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { "maps requestUserInput requests and answered notifications to canonical user-input events", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( Effect.forkChild, ); - lifecycleManager.emit("event", { + yield* runtime.emit({ id: asEventId("evt-user-input-requested"), kind: "request", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "item/tool/requestUserInput", requestId: ApprovalRequestId.make("req-user-input-1"), payload: { + itemId: "item-user-input-1", + threadId: "thread-1", + turnId: "turn-1", questions: [ { id: "sandbox_mode", @@ -716,17 +926,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { description: "Allow workspace writes only", }, ], - multiSelect: true, }, ], }, } satisfies ProviderEvent); - lifecycleManager.emit("event", { + yield* runtime.emit({ id: asEventId("evt-user-input-resolved"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "item/tool/requestUserInput/answered", requestId: ApprovalRequestId.make("req-user-input-1"), payload: { @@ -743,7 +952,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (events[0]?.type === "user-input.requested") { assert.equal(events[0].requestId, "req-user-input-1"); assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); - assert.equal(events[0].payload.questions[0]?.multiSelect, true); + assert.equal(events[0].payload.questions[0]?.multiSelect, false); } assert.equal(events[1]?.type, "user-input.resolved"); @@ -756,174 +965,18 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); - it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 5)).pipe( - Effect.forkChild, - ); - - lifecycleManager.emit("event", { - id: asEventId("evt-codex-task-started"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "codex/event/task_started", - payload: { - id: "turn-structured-1", - msg: { - type: "task_started", - turn_id: "turn-structured-1", - collaboration_mode_kind: "plan", - }, - }, - } satisfies ProviderEvent); - - lifecycleManager.emit("event", { - id: asEventId("evt-codex-agent-reasoning"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "codex/event/agent_reasoning", - payload: { - id: "turn-structured-1", - msg: { - type: "agent_reasoning", - text: "Need to compare both transport layers before finalizing the plan.", - }, - }, - } satisfies ProviderEvent); - - lifecycleManager.emit("event", { - id: asEventId("evt-codex-reasoning-delta"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "codex/event/reasoning_content_delta", - payload: { - id: "turn-structured-1", - msg: { - type: "reasoning_content_delta", - turn_id: "turn-structured-1", - item_id: "rs_reasoning_1", - delta: "**Compare** transport boundaries", - summary_index: 0, - }, - }, - } satisfies ProviderEvent); - - lifecycleManager.emit("event", { - id: asEventId("evt-codex-task-complete"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "codex/event/task_complete", - payload: { - id: "turn-structured-1", - msg: { - type: "task_complete", - turn_id: "turn-structured-1", - last_agent_message: "\n# Ship it\n", - }, - }, - } satisfies ProviderEvent); - - const events = Array.from(yield* Fiber.join(eventsFiber)); - - assert.equal(events[0]?.type, "task.started"); - if (events[0]?.type === "task.started") { - assert.equal(events[0].turnId, "turn-structured-1"); - assert.equal(events[0].payload.taskId, "turn-structured-1"); - assert.equal(events[0].payload.taskType, "plan"); - } - - assert.equal(events[1]?.type, "task.progress"); - if (events[1]?.type === "task.progress") { - assert.equal(events[1].payload.taskId, "turn-structured-1"); - assert.equal( - events[1].payload.description, - "Need to compare both transport layers before finalizing the plan.", - ); - } - - assert.equal(events[2]?.type, "content.delta"); - if (events[2]?.type === "content.delta") { - assert.equal(events[2].turnId, "turn-structured-1"); - assert.equal(events[2].itemId, "rs_reasoning_1"); - assert.equal(events[2].payload.streamKind, "reasoning_summary_text"); - assert.equal(events[2].payload.summaryIndex, 0); - } - - assert.equal(events[3]?.type, "task.completed"); - if (events[3]?.type === "task.completed") { - assert.equal(events[3].turnId, "turn-structured-1"); - assert.equal(events[3].payload.taskId, "turn-structured-1"); - assert.equal(events[3].payload.summary, "\n# Ship it\n"); - } - - assert.equal(events[4]?.type, "turn.proposed.completed"); - if (events[4]?.type === "turn.proposed.completed") { - assert.equal(events[4].turnId, "turn-structured-1"); - assert.equal(events[4].payload.planMarkdown, "# Ship it"); - } - }), - ); - - it.effect("prefers manager-assigned turn ids for Codex task events", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - - lifecycleManager.emit("event", { - id: asEventId("evt-codex-task-started-parent-turn"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-parent"), - createdAt: new Date().toISOString(), - method: "codex/event/task_started", - payload: { - id: "turn-child", - msg: { - type: "task_started", - turn_id: "turn-child", - collaboration_mode_kind: "default", - }, - conversationId: "child-provider-thread", - }, - } satisfies ProviderEvent); - - const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); - if (firstEvent._tag !== "Some") { - return; - } - assert.equal(firstEvent.value.type, "task.started"); - if (firstEvent.value.type !== "task.started") { - return; - } - assert.equal(firstEvent.value.turnId, "turn-parent"); - assert.equal(firstEvent.value.providerRefs?.providerTurnId, "turn-parent"); - assert.equal(firstEvent.value.payload.taskId, "turn-child"); - }), - ); - it.effect("unwraps Codex token usage payloads for context window events", () => Effect.gen(function* () { - const adapter = yield* CodexAdapter; + const { adapter, runtime } = yield* startLifecycleRuntime(); const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - lifecycleManager.emit("event", { + yield* runtime.emit({ id: asEventId("evt-codex-thread-token-usage-updated"), kind: "notification", - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), - createdAt: new Date().toISOString(), + createdAt: "2026-01-01T00:00:00.000Z", method: "thread/tokenUsage/updated", payload: { threadId: "thread-1", @@ -977,9 +1030,152 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { ); }); -afterAll(() => { - if (lifecycleManager.stopAllImpl.mock.calls.length === 0) { - lifecycleManager.stopAll(); - } - assert.ok(lifecycleManager.stopAllImpl.mock.calls.length >= 1); +const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); +const scopedLifecycleLayer = it.layer( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: scopedLifecycleRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +scopedLifecycleLayer("CodexAdapterLive scoped lifecycle", (it) => { + it.effect("closes the externally owned session scope on stopSession", () => + Effect.gen(function* () { + scopedLifecycleRuntimeFactory.releasedThreadIds.length = 0; + const adapter = yield* CodexAdapter; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-stop"), + runtimeMode: "full-access", + }); + + const runtime = scopedLifecycleRuntimeFactory.lastRuntime; + assert.ok(runtime); + + yield* adapter.stopSession(asThreadId("thread-stop")); + + assert.equal(runtime.closeImpl.mock.calls.length, 1); + assert.deepStrictEqual(scopedLifecycleRuntimeFactory.releasedThreadIds, [ + asThreadId("thread-stop"), + ]); + assert.equal(yield* adapter.hasSession(asThreadId("thread-stop")), false); + }), + ); +}); + +const scopedFailureRuntimeFactory = makeScopedRuntimeFactory({ failConstruction: true }); +const scopedFailureLayer = it.layer( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: scopedFailureRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +scopedFailureLayer("CodexAdapterLive scoped startup failure", (it) => { + it.effect("closes the externally owned session scope when startSession fails", () => + Effect.gen(function* () { + scopedFailureRuntimeFactory.releasedThreadIds.length = 0; + const adapter = yield* CodexAdapter; + + const result = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-fail"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); + assert.deepStrictEqual(scopedFailureRuntimeFactory.releasedThreadIds, [ + asThreadId("thread-fail"), + ]); + assert.equal(yield* adapter.hasSession(asThreadId("thread-fail")), false); + }), + ); }); + +it.effect("flushes managed native logs when the adapter layer shuts down", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-codex-adapter-native-log-")); + const basePath = path.join(tempDir, "provider-native.ndjson"); + const runtimeFactory = makeRuntimeFactory(); + const scope = yield* Scope.make("sequential"); + let scopeClosed = false; + + try { + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: runtimeFactory.factory, + nativeEventLogPath: basePath, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + const context = yield* Layer.buildWithScope(layer, scope); + const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context)); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-logger"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + assert.ok(runtime); + + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + yield* runtime.emit({ + id: asEventId("evt-native-log"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-logger"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "process/stderr", + message: "native flush test", + } satisfies ProviderEvent); + yield* Fiber.join(firstEventFiber); + + yield* Scope.close(scope, Exit.void); + scopeClosed = true; + + const threadLogPath = path.join(tempDir, "thread-logger.log"); + assert.equal(fs.existsSync(threadLogPath), true); + const contents = fs.readFileSync(threadLogPath, "utf8"); + assert.match(contents, /NTIVE: .*"message":"native flush test"/); + } finally { + if (!scopeClosed) { + yield* Scope.close(scope, Exit.void); + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), +); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 60de91b79ac..28af1cda27b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1,108 +1,149 @@ /** * CodexAdapterLive - Scoped live implementation for the Codex provider adapter. * - * Wraps `CodexAppServerManager` behind the `CodexAdapter` service contract and - * maps manager failures into the shared `ProviderAdapterError` algebra. + * Wraps the typed Codex session runtime behind the `CodexAdapter` service + * contract and maps runtime failures into the shared `ProviderAdapterError` + * algebra. * * @module CodexAdapterLive */ import { type CanonicalItemType, type CanonicalRequestType, + type CodexSettings, + ProviderDriverKind, type ProviderEvent, + ProviderInstanceId, type ProviderRuntimeEvent, + type ProviderRequestKind, type ThreadTokenUsageSnapshot, type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, - RuntimeTaskId, ProviderApprovalDecision, - ProviderItemId, ThreadId, - TurnId, ProviderSendTurnInput, } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Queue, Schema, Context, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; import { - ProviderAdapterProcessError, ProviderAdapterRequestError, + ProviderAdapterProcessError, ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; -import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { - CodexAppServerManager, - type CodexAppServerStartSessionInput, -} from "../../codexAppServerManager.ts"; +import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { + CodexResumeCursorSchema, + CodexSessionRuntimeThreadIdMissingError, + makeCodexSessionRuntime, + type CodexSessionRuntimeError, + type CodexSessionRuntimeOptions, + type CodexSessionRuntimeShape, +} from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); +const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); +const isCodexSessionRuntimeThreadIdMissingError = Schema.is( + CodexSessionRuntimeThreadIdMissingError, +); +const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); -const PROVIDER = "codex" as const; +const PROVIDER = ProviderDriverKind.make("codex"); export interface CodexAdapterLiveOptions { - readonly manager?: CodexAppServerManager; - readonly makeManager?: (services?: Context.Context) => CodexAppServerManager; + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly makeRuntime?: ( + options: CodexSessionRuntimeOptions, + ) => Effect.Effect< + CodexSessionRuntimeShape, + CodexSessionRuntimeError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + >; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } -function toSessionError( +interface CodexAdapterSessionContext { + readonly threadId: ThreadId; + readonly scope: Scope.Closeable; + readonly runtime: CodexSessionRuntimeShape; + readonly eventFiber: Fiber.Fiber; + stopped: boolean; +} + +function mapCodexRuntimeError( threadId: ThreadId, - cause: unknown, -): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { - const normalized = cause instanceof Error ? cause.message.toLowerCase() : ""; - if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { - return new ProviderAdapterSessionNotFoundError({ + method: string, + error: CodexSessionRuntimeError, +): ProviderAdapterError { + if (isCodexAppServerProcessExitedError(error) || isCodexAppServerTransportError(error)) { + return new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId, - cause, + cause: error, }); } - if (normalized.includes("session is closed")) { - return new ProviderAdapterSessionClosedError({ + + if (isCodexSessionRuntimeThreadIdMissingError(error)) { + return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId, - cause, + cause: error, }); } - return undefined; -} -function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } return new ProviderAdapterRequestError({ provider: PROVIDER, method, - detail: cause instanceof Error ? `${method} failed: ${cause.message}` : `${method} failed`, - cause, + detail: error.message, + cause: error, }); } -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} +type CodexLifecycleItem = + | EffectCodexSchema.V2ItemStartedNotification["item"] + | EffectCodexSchema.V2ItemCompletedNotification["item"]; -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} +type CodexToolUserInputQuestion = + | EffectCodexSchema.ServerRequest__ToolRequestUserInputQuestion + | EffectCodexSchema.ToolRequestUserInputParams__ToolRequestUserInputQuestion; -function asArray(value: unknown): unknown[] | undefined { - return Array.isArray(value) ? value : undefined; +const ApprovalDecisionPayload = Schema.Struct({ + decision: ProviderApprovalDecision, +}); + +function readPayload( + schema: Schema.Schema, + payload: ProviderEvent["payload"], +): A | undefined { + const isPayload = Schema.is(schema); + return isPayload(payload) ? payload : undefined; } -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; +function trimText(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; } const FATAL_CODEX_STDERR_SNIPPETS = ["failed to connect to websocket"]; @@ -112,26 +153,20 @@ function isFatalCodexProcessStderrMessage(message: string): boolean { return FATAL_CODEX_STDERR_SNIPPETS.some((snippet) => normalized.includes(snippet)); } -function normalizeCodexTokenUsage(value: unknown): ThreadTokenUsageSnapshot | undefined { - const usage = asObject(value); - const totalUsage = asObject(usage?.total_token_usage ?? usage?.total); - const lastUsage = asObject(usage?.last_token_usage ?? usage?.last); - - const totalProcessedTokens = - asNumber(totalUsage?.total_tokens) ?? asNumber(totalUsage?.totalTokens); - const usedTokens = - asNumber(lastUsage?.total_tokens) ?? asNumber(lastUsage?.totalTokens) ?? totalProcessedTokens; +function normalizeCodexTokenUsage( + usage: EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification["tokenUsage"], +): ThreadTokenUsageSnapshot | undefined { + const totalProcessedTokens = usage.total.totalTokens; + const usedTokens = usage.last.totalTokens; if (usedTokens === undefined || usedTokens <= 0) { return undefined; } - const maxTokens = asNumber(usage?.model_context_window) ?? asNumber(usage?.modelContextWindow); - const inputTokens = asNumber(lastUsage?.input_tokens) ?? asNumber(lastUsage?.inputTokens); - const cachedInputTokens = - asNumber(lastUsage?.cached_input_tokens) ?? asNumber(lastUsage?.cachedInputTokens); - const outputTokens = asNumber(lastUsage?.output_tokens) ?? asNumber(lastUsage?.outputTokens); - const reasoningOutputTokens = - asNumber(lastUsage?.reasoning_output_tokens) ?? asNumber(lastUsage?.reasoningOutputTokens); + const maxTokens = usage.modelContextWindow ?? undefined; + const inputTokens = usage.last.inputTokens; + const cachedInputTokens = usage.last.cachedInputTokens; + const outputTokens = usage.last.outputTokens; + const reasoningOutputTokens = usage.last.reasoningOutputTokens; return { usedTokens, @@ -154,15 +189,9 @@ function normalizeCodexTokenUsage(value: unknown): ThreadTokenUsageSnapshot | un }; } -function toTurnId(value: string | undefined): TurnId | undefined { - return value?.trim() ? TurnId.make(value) : undefined; -} - -function toProviderItemId(value: string | undefined): ProviderItemId | undefined { - return value?.trim() ? ProviderItemId.make(value) : undefined; -} - -function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "interrupted" { +function toTurnStatus( + value: EffectCodexSchema.V2TurnCompletedNotification["turn"]["status"] | "cancelled", +): "completed" | "failed" | "cancelled" | "interrupted" { switch (value) { case "completed": case "failed": @@ -174,8 +203,8 @@ function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "i } } -function normalizeItemType(raw: unknown): string { - const type = asString(raw); +function normalizeItemType(raw: string | undefined | null): string { + const type = trimText(raw); if (!type) return "item"; return type .replace(/([a-z0-9])([A-Z])/g, "$1 $2") @@ -185,7 +214,7 @@ function normalizeItemType(raw: unknown): string { .toLowerCase(); } -function toCanonicalItemType(raw: unknown): CanonicalItemType { +function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType { const type = normalizeItemType(raw); if (type.includes("user")) return "user_message"; if (type.includes("agent message") || type.includes("assistant")) return "assistant_message"; @@ -235,27 +264,18 @@ function itemTitle(itemType: CanonicalItemType): string | undefined { } } -function itemDetail( - item: Record, - payload: Record, -): string | undefined { - const nestedResult = asObject(item.result); +function itemDetail(item: CodexLifecycleItem): string | undefined { const candidates = [ - asString(item.command), - asString(item.title), - asString(item.summary), - asString(item.text), - asString(item.path), - asString(item.prompt), - asString(nestedResult?.command), - asString(payload.command), - asString(payload.message), - asString(payload.prompt), + "command" in item ? item.command : undefined, + "title" in item ? item.title : undefined, + "summary" in item ? item.summary : undefined, + "text" in item ? item.text : undefined, + "path" in item ? item.path : undefined, + "prompt" in item ? item.prompt : undefined, ]; for (const candidate of candidates) { - if (!candidate) continue; - const trimmed = candidate.trim(); - if (trimmed.length === 0) continue; + const trimmed = typeof candidate === "string" ? trimText(candidate) : undefined; + if (!trimmed) continue; return trimmed; } return undefined; @@ -284,7 +304,7 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { } } -function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { +function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): CanonicalRequestType { switch (kind) { case "command": return "command_execution_approval"; @@ -297,77 +317,36 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { } } -function toRequestTypeFromResolvedPayload( - payload: Record | undefined, -): CanonicalRequestType { - const request = asObject(payload?.request); - const method = asString(request?.method) ?? asString(payload?.method); - if (method) { - return toRequestTypeFromMethod(method); - } - const requestKind = asString(request?.kind) ?? asString(payload?.requestKind); - if (requestKind) { - return toRequestTypeFromKind(requestKind); - } - return "unknown"; -} - function toCanonicalUserInputAnswers( - answers: ProviderUserInputAnswers | undefined, + answers: EffectCodexSchema.ToolRequestUserInputResponse["answers"], ): ProviderUserInputAnswers { - if (!answers) { - return {}; - } - return Object.fromEntries( - Object.entries(answers).flatMap(([questionId, value]) => { - if (typeof value === "string") { - return [[questionId, value] as const]; - } - - if (Array.isArray(value)) { - const normalized = value.filter((entry): entry is string => typeof entry === "string"); - return [[questionId, normalized.length === 1 ? normalized[0] : normalized] as const]; - } - - const answerObject = asObject(value); - const answerList = asArray(answerObject?.answers)?.filter( - (entry): entry is string => typeof entry === "string", - ); - if (!answerList) { - return []; - } - return [[questionId, answerList.length === 1 ? answerList[0] : answerList] as const]; + Object.entries(answers).map(([questionId, value]) => { + const normalizedAnswers = value.answers.length === 1 ? value.answers[0]! : [...value.answers]; + return [questionId, normalizedAnswers] as const; }), ); } -function toUserInputQuestions(payload: Record | undefined) { - const questions = asArray(payload?.questions); - if (!questions) { - return undefined; - } - +function toUserInputQuestions(questions: ReadonlyArray) { const parsedQuestions = questions - .map((entry) => { - const question = asObject(entry); - if (!question) return undefined; - const options = asArray(question.options) - ?.map((option) => { - const optionRecord = asObject(option); - if (!optionRecord) return undefined; - const label = asString(optionRecord.label)?.trim(); - const description = asString(optionRecord.description)?.trim(); - if (!label || !description) { - return undefined; - } - return { label, description }; - }) - .filter((option): option is { label: string; description: string } => option !== undefined); - const id = asString(question.id)?.trim(); - const header = asString(question.header)?.trim(); - const prompt = asString(question.question)?.trim(); - if (!id || !header || !prompt || !options || options.length === 0) { + .map((question) => { + const options = + question.options + ?.map((option) => { + const label = trimText(option.label); + const description = trimText(option.description); + if (!label || !description) { + return undefined; + } + return { label, description }; + }) + .filter((option) => option !== undefined) ?? []; + + const id = trimText(question.id); + const header = trimText(question.header); + const prompt = trimText(question.question); + if (!id || !header || !prompt || options.length === 0) { return undefined; } return { @@ -375,38 +354,21 @@ function toUserInputQuestions(payload: Record | undefined) { header, question: prompt, options, - multiSelect: question.multiSelect === true, + multiSelect: false, }; }) - .filter( - ( - question, - ): question is { - id: string; - header: string; - question: string; - options: Array<{ label: string; description: string }>; - multiSelect: boolean; - } => question !== undefined, - ); + .filter((question) => question !== undefined); return parsedQuestions.length > 0 ? parsedQuestions : undefined; } function toThreadState( - value: unknown, + status: EffectCodexSchema.V2ThreadStatusChangedNotification["status"], ): "active" | "idle" | "archived" | "closed" | "compacted" | "error" { - switch (value) { + switch (status.type) { case "idle": return "idle"; - case "archived": - return "archived"; - case "closed": - return "closed"; - case "compacted": - return "compacted"; - case "error": - case "failed": + case "systemError": return "error"; default: return "active"; @@ -438,15 +400,7 @@ function contentStreamKindFromMethod( } } -const PROPOSED_PLAN_BLOCK_REGEX = /\s*([\s\S]*?)\s*<\/proposed_plan>/i; - -function extractProposedPlanMarkdown(text: string | undefined): string | undefined { - const match = text ? PROPOSED_PLAN_BLOCK_REGEX.exec(text) : null; - const planMarkdown = match?.[1]?.trim(); - return planMarkdown && planMarkdown.length > 0 ? planMarkdown : undefined; -} - -function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId { +function asRuntimeItemId(itemId: ProviderEvent["itemId"] & string): RuntimeItemId { return RuntimeItemId.make(itemId); } @@ -454,48 +408,6 @@ function asRuntimeRequestId(requestId: string): RuntimeRequestId { return RuntimeRequestId.make(requestId); } -function asRuntimeTaskId(taskId: string): RuntimeTaskId { - return RuntimeTaskId.make(taskId); -} - -function codexEventMessage( - payload: Record | undefined, -): Record | undefined { - return asObject(payload?.msg); -} - -function codexEventBase( - event: ProviderEvent, - canonicalThreadId: ThreadId, -): Omit { - const payload = asObject(event.payload); - const msg = codexEventMessage(payload); - const turnId = event.turnId ?? toTurnId(asString(msg?.turn_id) ?? asString(msg?.turnId)); - const itemId = event.itemId ?? toProviderItemId(asString(msg?.item_id) ?? asString(msg?.itemId)); - const requestId = asString(msg?.request_id) ?? asString(msg?.requestId); - const base = runtimeEventBase(event, canonicalThreadId); - const providerRefs = base.providerRefs - ? { - ...base.providerRefs, - ...(turnId ? { providerTurnId: turnId } : {}), - ...(itemId ? { providerItemId: itemId } : {}), - ...(requestId ? { providerRequestId: requestId } : {}), - } - : { - ...(turnId ? { providerTurnId: turnId } : {}), - ...(itemId ? { providerItemId: itemId } : {}), - ...(requestId ? { providerRequestId: requestId } : {}), - }; - - return { - ...base, - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId: asRuntimeItemId(itemId) } : {}), - ...(requestId ? { requestId: asRuntimeRequestId(requestId) } : {}), - ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}), - }; -} - function eventRawSource(event: ProviderEvent): NonNullable["source"] { return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification"; } @@ -538,19 +450,19 @@ function mapItemLifecycle( canonicalThreadId: ThreadId, lifecycle: "item.started" | "item.updated" | "item.completed", ): ProviderRuntimeEvent | undefined { - const payload = asObject(event.payload); - const item = asObject(payload?.item); - const source = item ?? payload; - if (!source) { + const payload = + readPayload(EffectCodexSchema.V2ItemStartedNotification, event.payload) ?? + readPayload(EffectCodexSchema.V2ItemCompletedNotification, event.payload); + const item = payload?.item; + if (!item) { return undefined; } - - const itemType = toCanonicalItemType(source.type ?? source.kind); + const itemType = toCanonicalItemType(item.type); if (itemType === "unknown" && lifecycle !== "item.updated") { return undefined; } - const detail = itemDetail(source, payload ?? {}); + const detail = itemDetail(item); const status = lifecycle === "item.started" ? "inProgress" @@ -575,9 +487,6 @@ function mapToRuntimeEvents( event: ProviderEvent, canonicalThreadId: ThreadId, ): ReadonlyArray { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - if (event.kind === "error") { if (!event.message) { return []; @@ -597,7 +506,10 @@ function mapToRuntimeEvents( if (event.kind === "request") { if (event.method === "item/tool/requestUserInput") { - const questions = toUserInputQuestions(payload); + const payload = + readPayload(EffectCodexSchema.ServerRequest__ToolRequestUserInputParams, event.payload) ?? + readPayload(EffectCodexSchema.ToolRequestUserInputParams, event.payload); + const questions = payload ? toUserInputQuestions(payload.questions) : undefined; if (!questions) { return []; } @@ -612,8 +524,48 @@ function mapToRuntimeEvents( ]; } - const detail = - asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt); + const detail = (() => { + switch (event.method) { + case "item/commandExecution/requestApproval": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__CommandExecutionRequestApprovalParams, + event.payload, + ); + return payload?.command ?? payload?.reason ?? undefined; + } + case "item/fileChange/requestApproval": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__FileChangeRequestApprovalParams, + event.payload, + ); + return payload?.reason ?? undefined; + } + case "applyPatchApproval": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, + event.payload, + ); + return payload?.reason ?? undefined; + } + case "execCommandApproval": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__ExecCommandApprovalParams, + event.payload, + ); + return payload?.reason ?? payload?.command.join(" "); + } + case "item/tool/call": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__DynamicToolCallParams, + event.payload, + ); + return payload?.tool ?? undefined; + } + default: + return undefined; + } + })(); + return [ { ...runtimeEventBase(event, canonicalThreadId), @@ -628,7 +580,7 @@ function mapToRuntimeEvents( } if (event.method === "item/requestApproval/decision" && event.requestId) { - const decision = Schema.decodeUnknownSync(ProviderApprovalDecision)(payload?.decision); + const payload = readPayload(ApprovalDecisionPayload, event.payload); const requestType = event.requestKind !== undefined ? toRequestTypeFromKind(event.requestKind) @@ -639,7 +591,7 @@ function mapToRuntimeEvents( type: "request.resolved", payload: { requestType, - ...(decision ? { decision } : {}), + ...(payload ? { decision: payload.decision } : {}), ...(event.payload !== undefined ? { resolution: event.payload } : {}), }, }, @@ -699,9 +651,8 @@ function mapToRuntimeEvents( } if (event.method === "thread/started") { - const payloadThreadId = asString(asObject(payload?.thread)?.id); - const providerThreadId = payloadThreadId ?? asString(payload?.threadId); - if (!providerThreadId) { + const payload = readPayload(EffectCodexSchema.V2ThreadStartedNotification, event.payload); + if (!payload) { return []; } return [ @@ -709,7 +660,7 @@ function mapToRuntimeEvents( ...runtimeEventBase(event, canonicalThreadId), type: "thread.started", payload: { - providerThreadId, + providerThreadId: payload.thread.id, }, }, ]; @@ -722,6 +673,10 @@ function mapToRuntimeEvents( event.method === "thread/closed" || event.method === "thread/compacted" ) { + const payload = + event.method === "thread/status/changed" + ? readPayload(EffectCodexSchema.V2ThreadStatusChangedNotification, event.payload) + : undefined; return [ { type: "thread.state.changed", @@ -734,7 +689,9 @@ function mapToRuntimeEvents( ? "closed" : event.method === "thread/compacted" ? "compacted" - : toThreadState(asObject(payload?.thread)?.state ?? payload?.state), + : payload + ? toThreadState(payload.status) + : "active", ...(event.payload !== undefined ? { detail: event.payload } : {}), }, }, @@ -742,21 +699,34 @@ function mapToRuntimeEvents( } if (event.method === "thread/name/updated") { + const payload = readPayload(EffectCodexSchema.V2ThreadNameUpdatedNotification, event.payload); return [ { type: "thread.metadata.updated", ...runtimeEventBase(event, canonicalThreadId), payload: { - ...(asString(payload?.threadName) ? { name: asString(payload?.threadName) } : {}), - ...(event.payload !== undefined ? { metadata: asObject(event.payload) } : {}), + ...(trimText(payload?.threadName) ? { name: trimText(payload?.threadName) } : {}), + ...(payload + ? { + metadata: { + threadId: payload.threadId, + ...(payload.threadName !== undefined && payload.threadName !== null + ? { threadName: payload.threadName } + : {}), + }, + } + : {}), }, }, ]; } if (event.method === "thread/tokenUsage/updated") { - const tokenUsage = asObject(payload?.tokenUsage); - const normalizedUsage = normalizeCodexTokenUsage(tokenUsage ?? event.payload); + const payload = readPayload( + EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification, + event.payload, + ); + const normalizedUsage = payload ? normalizeCodexTokenUsage(payload.tokenUsage) : undefined; if (!normalizedUsage) { return []; } @@ -781,28 +751,23 @@ function mapToRuntimeEvents( ...runtimeEventBase(event, canonicalThreadId), turnId, type: "turn.started", - payload: { - ...(asString(turn?.model) ? { model: asString(turn?.model) } : {}), - ...(asString(turn?.effort) ? { effort: asString(turn?.effort) } : {}), - }, + payload: {}, }, ]; } if (event.method === "turn/completed") { - const errorMessage = asString(asObject(turn?.error)?.message); + const payload = readPayload(EffectCodexSchema.V2TurnCompletedNotification, event.payload); + if (!payload) { + return []; + } + const errorMessage = trimText(payload.turn.error?.message); return [ { ...runtimeEventBase(event, canonicalThreadId), type: "turn.completed", payload: { - state: toTurnStatus(turn?.status), - ...(asString(turn?.stopReason) ? { stopReason: asString(turn?.stopReason) } : {}), - ...(turn?.usage !== undefined ? { usage: turn.usage } : {}), - ...(asObject(turn?.modelUsage) ? { modelUsage: asObject(turn?.modelUsage) } : {}), - ...(asNumber(turn?.totalCostUsd) !== undefined - ? { totalCostUsd: asNumber(turn?.totalCostUsd) } - : {}), + state: toTurnStatus(payload.turn.status), ...(errorMessage ? { errorMessage } : {}), }, }, @@ -822,41 +787,37 @@ function mapToRuntimeEvents( } if (event.method === "turn/plan/updated") { - const steps = Array.isArray(payload?.plan) ? payload.plan : []; + const payload = readPayload(EffectCodexSchema.V2TurnPlanUpdatedNotification, event.payload); + if (!payload) { + return []; + } return [ { ...runtimeEventBase(event, canonicalThreadId), type: "turn.plan.updated", payload: { - ...(asString(payload?.explanation) - ? { explanation: asString(payload?.explanation) } - : {}), - plan: steps - .map((entry) => asObject(entry)) - .filter((entry): entry is Record => entry !== undefined) - .map((entry) => ({ - step: asString(entry.step) ?? "step", - status: - entry.status === "completed" || entry.status === "inProgress" - ? entry.status - : "pending", - })), + ...(trimText(payload.explanation) ? { explanation: trimText(payload.explanation) } : {}), + plan: payload.plan.map((step) => ({ + step: trimText(step.step) ?? "step", + status: + step.status === "completed" || step.status === "inProgress" ? step.status : "pending", + })), }, }, ]; } if (event.method === "turn/diff/updated") { + const payload = readPayload(EffectCodexSchema.V2TurnDiffUpdatedNotification, event.payload); + if (!payload) { + return []; + } return [ { ...runtimeEventBase(event, canonicalThreadId), type: "turn.diff.updated", payload: { - unifiedDiff: - asString(payload?.unifiedDiff) ?? - asString(payload?.diff) ?? - asString(payload?.patch) ?? - "", + unifiedDiff: payload.diff, }, }, ]; @@ -868,15 +829,14 @@ function mapToRuntimeEvents( } if (event.method === "item/completed") { - const payload = asObject(event.payload); - const item = asObject(payload?.item); - const source = item ?? payload; - if (!source) { + const payload = readPayload(EffectCodexSchema.V2ItemCompletedNotification, event.payload); + const item = payload?.item; + if (!item) { return []; } - const itemType = source ? toCanonicalItemType(source.type ?? source.kind) : "unknown"; + const itemType = toCanonicalItemType(item.type); if (itemType === "plan") { - const detail = itemDetail(source, payload ?? {}); + const detail = itemDetail(item); if (!detail) { return []; } @@ -898,16 +858,22 @@ function mapToRuntimeEvents( event.method === "item/reasoning/summaryPartAdded" || event.method === "item/commandExecution/terminalInteraction" ) { - const updated = mapItemLifecycle(event, canonicalThreadId, "item.updated"); - return updated ? [updated] : []; + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "item.updated", + payload: { + itemType: + event.method === "item/reasoning/summaryPartAdded" ? "reasoning" : "command_execution", + ...(event.payload !== undefined ? { data: event.payload } : {}), + }, + }, + ]; } if (event.method === "item/plan/delta") { - const delta = - event.textDelta ?? - asString(payload?.delta) ?? - asString(payload?.text) ?? - asString(asObject(payload?.content)?.text); + const payload = readPayload(EffectCodexSchema.V2PlanDeltaNotification, event.payload); + const delta = event.textDelta ?? payload?.delta; if (!delta || delta.length === 0) { return []; } @@ -922,18 +888,9 @@ function mapToRuntimeEvents( ]; } - if ( - event.method === "item/agentMessage/delta" || - event.method === "item/commandExecution/outputDelta" || - event.method === "item/fileChange/outputDelta" || - event.method === "item/reasoning/summaryTextDelta" || - event.method === "item/reasoning/textDelta" - ) { - const delta = - event.textDelta ?? - asString(payload?.delta) ?? - asString(payload?.text) ?? - asString(asObject(payload?.content)?.text); + if (event.method === "item/agentMessage/delta") { + const payload = readPayload(EffectCodexSchema.V2AgentMessageDeltaNotification, event.payload); + const delta = event.textDelta ?? payload?.delta; if (!delta || delta.length === 0) { return []; } @@ -944,216 +901,207 @@ function mapToRuntimeEvents( payload: { streamKind: contentStreamKindFromMethod(event.method), delta, - ...(typeof payload?.contentIndex === "number" - ? { contentIndex: payload.contentIndex } - : {}), - ...(typeof payload?.summaryIndex === "number" - ? { summaryIndex: payload.summaryIndex } - : {}), }, }, ]; } - if (event.method === "item/mcpToolCall/progress") { + if (event.method === "item/commandExecution/outputDelta") { + const payload = readPayload( + EffectCodexSchema.V2CommandExecutionOutputDeltaNotification, + event.payload, + ); + const delta = event.textDelta ?? payload?.delta; + if (!delta || delta.length === 0) { + return []; + } return [ { ...runtimeEventBase(event, canonicalThreadId), - type: "tool.progress", + type: "content.delta", payload: { - ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}), - ...(asString(payload?.toolName) ? { toolName: asString(payload?.toolName) } : {}), - ...(asString(payload?.summary) ? { summary: asString(payload?.summary) } : {}), - ...(asNumber(payload?.elapsedSeconds) !== undefined - ? { elapsedSeconds: asNumber(payload?.elapsedSeconds) } - : {}), + streamKind: "command_output", + delta, }, }, ]; } - if (event.method === "serverRequest/resolved") { - const requestType = - toRequestTypeFromResolvedPayload(payload) !== "unknown" - ? toRequestTypeFromResolvedPayload(payload) - : event.requestId && event.requestKind !== undefined - ? toRequestTypeFromKind(event.requestKind) - : "unknown"; + if (event.method === "item/fileChange/outputDelta") { + const payload = readPayload( + EffectCodexSchema.V2FileChangeOutputDeltaNotification, + event.payload, + ); + const delta = event.textDelta ?? payload?.delta; + if (!delta || delta.length === 0) { + return []; + } return [ { ...runtimeEventBase(event, canonicalThreadId), - type: "request.resolved", + type: "content.delta", payload: { - requestType, - ...(event.payload !== undefined ? { resolution: event.payload } : {}), + streamKind: "file_change_output", + delta, }, }, ]; } - if (event.method === "item/tool/requestUserInput/answered") { + if (event.method === "item/reasoning/summaryTextDelta") { + const payload = readPayload( + EffectCodexSchema.V2ReasoningSummaryTextDeltaNotification, + event.payload, + ); + const delta = event.textDelta ?? payload?.delta; + if (!delta || delta.length === 0) { + return []; + } return [ { ...runtimeEventBase(event, canonicalThreadId), - type: "user-input.resolved", + type: "content.delta", payload: { - answers: toCanonicalUserInputAnswers( - asObject(event.payload)?.answers as ProviderUserInputAnswers | undefined, - ), + streamKind: "reasoning_summary_text", + delta, + ...(payload ? { summaryIndex: payload.summaryIndex } : {}), }, }, ]; } - if (event.method === "codex/event/task_started") { - const msg = codexEventMessage(payload); - const taskId = asString(payload?.id) ?? asString(msg?.turn_id); - if (!taskId) { + if (event.method === "item/reasoning/textDelta") { + const payload = readPayload(EffectCodexSchema.V2ReasoningTextDeltaNotification, event.payload); + const delta = event.textDelta ?? payload?.delta; + if (!delta || delta.length === 0) { return []; } return [ { - ...codexEventBase(event, canonicalThreadId), - type: "task.started", + ...runtimeEventBase(event, canonicalThreadId), + type: "content.delta", payload: { - taskId: asRuntimeTaskId(taskId), - ...(asString(msg?.collaboration_mode_kind) - ? { taskType: asString(msg?.collaboration_mode_kind) } - : {}), + streamKind: "reasoning_text", + delta, + ...(payload ? { contentIndex: payload.contentIndex } : {}), }, }, ]; } - if (event.method === "codex/event/task_complete") { - const msg = codexEventMessage(payload); - const taskId = asString(payload?.id) ?? asString(msg?.turn_id); - const proposedPlanMarkdown = extractProposedPlanMarkdown(asString(msg?.last_agent_message)); - if (!taskId) { - if (!proposedPlanMarkdown) { - return []; - } - return [ - { - ...codexEventBase(event, canonicalThreadId), - type: "turn.proposed.completed", - payload: { - planMarkdown: proposedPlanMarkdown, - }, - }, - ]; + if (event.method === "item/mcpToolCall/progress") { + const payload = readPayload(EffectCodexSchema.V2McpToolCallProgressNotification, event.payload); + if (!payload) { + return []; } - const events: ProviderRuntimeEvent[] = [ + return [ { - ...codexEventBase(event, canonicalThreadId), - type: "task.completed", + ...runtimeEventBase(event, canonicalThreadId), + type: "tool.progress", payload: { - taskId: asRuntimeTaskId(taskId), - status: "completed", - ...(asString(msg?.last_agent_message) - ? { summary: asString(msg?.last_agent_message) } - : {}), + summary: payload.message, }, }, ]; - if (proposedPlanMarkdown) { - events.push({ - ...codexEventBase(event, canonicalThreadId), - type: "turn.proposed.completed", - payload: { - planMarkdown: proposedPlanMarkdown, - }, - }); - } - return events; } - if (event.method === "codex/event/agent_reasoning") { - const msg = codexEventMessage(payload); - const taskId = asString(payload?.id); - const description = asString(msg?.text); - if (!taskId || !description) { + if (event.method === "serverRequest/resolved") { + const payload = readPayload( + EffectCodexSchema.V2ServerRequestResolvedNotification, + event.payload, + ); + if (!payload) { return []; } + const requestType = toRequestTypeFromKind(event.requestKind); return [ { - ...codexEventBase(event, canonicalThreadId), - type: "task.progress", + ...runtimeEventBase(event, canonicalThreadId), + type: "request.resolved", payload: { - taskId: asRuntimeTaskId(taskId), - description, + requestType, + ...(event.payload !== undefined ? { resolution: event.payload } : {}), }, }, ]; } - if (event.method === "codex/event/reasoning_content_delta") { - const msg = codexEventMessage(payload); - const delta = asString(msg?.delta); - if (!delta) { + if (event.method === "item/tool/requestUserInput/answered") { + const payload = readPayload(EffectCodexSchema.ToolRequestUserInputResponse, event.payload); + if (!payload) { return []; } return [ { - ...codexEventBase(event, canonicalThreadId), - type: "content.delta", + ...runtimeEventBase(event, canonicalThreadId), + type: "user-input.resolved", payload: { - streamKind: - asNumber(msg?.summary_index) !== undefined - ? "reasoning_summary_text" - : "reasoning_text", - delta, - ...(asNumber(msg?.summary_index) !== undefined - ? { summaryIndex: asNumber(msg?.summary_index) } - : {}), + answers: toCanonicalUserInputAnswers(payload.answers), }, }, ]; } if (event.method === "model/rerouted") { + const payload = readPayload(EffectCodexSchema.V2ModelReroutedNotification, event.payload); + if (!payload) { + return []; + } return [ { type: "model.rerouted", ...runtimeEventBase(event, canonicalThreadId), payload: { - fromModel: asString(payload?.fromModel) ?? "unknown", - toModel: asString(payload?.toModel) ?? "unknown", - reason: asString(payload?.reason) ?? "unknown", + fromModel: payload.fromModel, + toModel: payload.toModel, + reason: payload.reason, }, }, ]; } if (event.method === "deprecationNotice") { + const payload = readPayload(EffectCodexSchema.V2DeprecationNoticeNotification, event.payload); + if (!payload) { + return []; + } return [ { type: "deprecation.notice", ...runtimeEventBase(event, canonicalThreadId), payload: { - summary: asString(payload?.summary) ?? "Deprecation notice", - ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), + summary: payload.summary, + ...(trimText(payload.details) ? { details: trimText(payload.details) } : {}), }, }, ]; } if (event.method === "configWarning") { + const payload = readPayload(EffectCodexSchema.V2ConfigWarningNotification, event.payload); + if (!payload) { + return []; + } return [ { type: "config.warning", ...runtimeEventBase(event, canonicalThreadId), payload: { - summary: asString(payload?.summary) ?? "Configuration warning", - ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), - ...(asString(payload?.path) ? { path: asString(payload?.path) } : {}), - ...(payload?.range !== undefined ? { range: payload.range } : {}), + summary: payload.summary, + ...(trimText(payload.details) ? { details: trimText(payload.details) } : {}), + ...(trimText(payload.path) ? { path: trimText(payload.path) } : {}), + ...(payload.range !== undefined && payload.range !== null + ? { range: payload.range } + : {}), }, }, ]; } if (event.method === "account/updated") { + if (!readPayload(EffectCodexSchema.V2AccountUpdatedNotification, event.payload)) { + return []; + } return [ { type: "account.updated", @@ -1166,6 +1114,9 @@ function mapToRuntimeEvents( } if (event.method === "account/rateLimits/updated") { + if (!readPayload(EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, event.payload)) { + return []; + } return [ { type: "account.rate-limits.updated", @@ -1178,58 +1129,86 @@ function mapToRuntimeEvents( } if (event.method === "mcpServer/oauthLogin/completed") { + const payload = readPayload( + EffectCodexSchema.V2McpServerOauthLoginCompletedNotification, + event.payload, + ); + if (!payload) { + return []; + } return [ { type: "mcp.oauth.completed", ...runtimeEventBase(event, canonicalThreadId), payload: { - success: payload?.success === true, - ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}), - ...(asString(payload?.error) ? { error: asString(payload?.error) } : {}), + success: payload.success, + name: payload.name, + ...(trimText(payload.error) ? { error: trimText(payload.error) } : {}), }, }, ]; } if (event.method === "thread/realtime/started") { - const realtimeSessionId = asString(payload?.realtimeSessionId); + const payload = readPayload( + EffectCodexSchema.V2ThreadRealtimeStartedNotification, + event.payload, + ); + if (!payload) { + return []; + } return [ { type: "thread.realtime.started", ...runtimeEventBase(event, canonicalThreadId), payload: { - realtimeSessionId, + realtimeSessionId: payload.realtimeSessionId ?? undefined, }, }, ]; } if (event.method === "thread/realtime/itemAdded") { + const payload = readPayload( + EffectCodexSchema.V2ThreadRealtimeItemAddedNotification, + event.payload, + ); + if (!payload) { + return []; + } return [ { type: "thread.realtime.item-added", ...runtimeEventBase(event, canonicalThreadId), payload: { - item: event.payload ?? {}, + item: payload.item, }, }, ]; } if (event.method === "thread/realtime/outputAudio/delta") { + const payload = readPayload( + EffectCodexSchema.V2ThreadRealtimeOutputAudioDeltaNotification, + event.payload, + ); + if (!payload) { + return []; + } return [ { type: "thread.realtime.audio.delta", ...runtimeEventBase(event, canonicalThreadId), payload: { - audio: event.payload ?? {}, + audio: payload.audio, }, }, ]; } if (event.method === "thread/realtime/error") { - const message = asString(payload?.message) ?? event.message ?? "Realtime error"; + const payload = readPayload(EffectCodexSchema.V2ThreadRealtimeErrorNotification, event.payload); + const message = payload?.message ?? event.message ?? "Realtime error"; return [ { type: "thread.realtime.error", @@ -1242,20 +1221,24 @@ function mapToRuntimeEvents( } if (event.method === "thread/realtime/closed") { + const payload = readPayload( + EffectCodexSchema.V2ThreadRealtimeClosedNotification, + event.payload, + ); return [ { type: "thread.realtime.closed", ...runtimeEventBase(event, canonicalThreadId), payload: { - reason: event.message, + reason: payload?.reason ?? event.message, }, }, ]; } if (event.method === "error") { - const message = - asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error"; + const payload = readPayload(EffectCodexSchema.V2ErrorNotification, event.payload); + const message = payload?.error.message ?? event.message ?? "Provider runtime error"; const willRetry = payload?.willRetry === true; return [ { @@ -1296,6 +1279,9 @@ function mapToRuntimeEvents( } if (event.method === "windows/worldWritableWarning") { + if (!readPayload(EffectCodexSchema.V2WindowsWorldWritableWarningNotification, event.payload)) { + return []; + } return [ { type: "runtime.warning", @@ -1309,8 +1295,13 @@ function mapToRuntimeEvents( } if (event.method === "windowsSandbox/setupCompleted") { - const payloadRecord = asObject(event.payload); - const success = payloadRecord?.success; + const payload = readPayload( + EffectCodexSchema.V2WindowsSandboxSetupCompletedNotification, + event.payload, + ); + if (!payload) { + return []; + } const successMessage = event.message ?? "Windows sandbox setup completed"; const failureMessage = event.message ?? "Windows sandbox setup failed"; @@ -1319,12 +1310,12 @@ function mapToRuntimeEvents( type: "session.state.changed", ...runtimeEventBase(event, canonicalThreadId), payload: { - state: success === false ? "error" : "ready", - reason: success === false ? failureMessage : successMessage, + state: payload.success === false ? "error" : "ready", + reason: payload.success === false ? failureMessage : successMessage, ...(event.payload !== undefined ? { detail: event.payload } : {}), }, }, - ...(success === false + ...(payload.success === false ? [ { type: "runtime.warning" as const, @@ -1342,10 +1333,22 @@ function mapToRuntimeEvents( return []; } -const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( +/** + * Build a Codex provider adapter bound to a specific `CodexSettings` payload. + * + * The adapter is a captured closure over `codexConfig` — the `binaryPath` and + * `homePath` are read from that payload, not from `ServerSettingsService`. + * This is what makes multi-instance routing possible: each `ProviderInstance` + * in the registry owns its own closure with its own config, so two Codex + * instances with different `homePath`s cannot step on each other. + */ +export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( + codexConfig: CodexSettings, options?: CodexAdapterLiveOptions, ) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const fileSystem = yield* FileSystem.FileSystem; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); const nativeEventLogger = options?.nativeEventLogger ?? @@ -1354,78 +1357,114 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( stream: "native", }) : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); - const acquireManager = Effect.fn("acquireManager")(function* () { - if (options?.manager) { - return options.manager; - } - const services = yield* Effect.context(); - return options?.makeManager?.(services) ?? new CodexAppServerManager(services); - }); + const startSession: CodexAdapterShape["startSession"] = (input) => + Effect.scoped( + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } - const manager = yield* Effect.acquireRelease(acquireManager(), (manager) => - Effect.sync(() => { - try { - manager.stopAll(); - } catch { - // Finalizers should never fail and block shutdown. - } - }), - ); - const serverSettingsService = yield* ServerSettingsService; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* Effect.suspend(() => stopSessionInternal(existing)); + } - const startSession: CodexAdapterShape["startSession"] = Effect.fn("startSession")( - function* (input) { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } + const runtimeInput: CodexSessionRuntimeOptions = { + threadId: input.threadId, + providerInstanceId: boundInstanceId, + cwd: input.cwd ?? process.cwd(), + binaryPath: codexConfig.binaryPath, + ...(options?.environment ? { environment: options.environment } : {}), + ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + ...(isCodexResumeCursorSchema(input.resumeCursor) + ? { resumeCursor: input.resumeCursor } + : {}), + runtimeMode: input.runtimeMode, + ...(input.modelSelection?.instanceId === boundInstanceId + ? { model: input.modelSelection.model } + : {}), + ...(input.modelSelection?.instanceId === boundInstanceId && + getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") === true + ? { serviceTier: "fast" } + : {}), + }; + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; + const runtime = yield* createRuntime(runtimeInput).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const eventFiber = yield* Stream.runForEach(runtime.events, (event) => + Effect.gen(function* () { + yield* writeNativeEvent(event); + const runtimeEvents = mapToRuntimeEvents(event, event.threadId); + if (runtimeEvents.length === 0) { + yield* Effect.logDebug("ignoring unhandled Codex provider event", { + method: event.method, + threadId: event.threadId, + turnId: event.turnId, + itemId: event.itemId, + }); + return; + } + yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); + }), + ).pipe(Effect.forkChild); + + const started = yield* runtime.start().pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + Effect.onError(() => + runtime.close.pipe( + Effect.andThen(Effect.ignore(Scope.close(sessionScope, Exit.void))), + Effect.andThen(Fiber.interrupt(eventFiber)), + Effect.ignore, + ), + ), + ); - const codexSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.codex), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); - const binaryPath = codexSettings.binaryPath; - const homePath = codexSettings.homePath; - const managerInput: CodexAppServerStartSessionInput = { - threadId: input.threadId, - provider: "codex", - ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), - ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - runtimeMode: input.runtimeMode, - binaryPath, - ...(homePath ? { homePath } : {}), - ...(input.modelSelection?.provider === "codex" - ? { model: input.modelSelection.model } - : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode - ? { serviceTier: "fast" } - : {}), - }; + sessions.set(input.threadId, { + threadId: input.threadId, + scope: sessionScope, + runtime, + eventFiber, + stopped: false, + }); + sessionScopeTransferred = true; - return yield* Effect.tryPromise({ - try: () => manager.startSession(managerInput), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: `Failed to start Codex adapter session: ${cause instanceof Error ? cause.message : String(cause)}.`, - cause, - }), - }); - }, - ); + return started; + }), + ); const resolveAttachment = Effect.fn("resolveAttachment")(function* ( input: ProviderSendTurnInput, @@ -1436,11 +1475,11 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( attachment, }); if (!attachmentPath) { - return yield* toRequestError( - input.threadId, - "turn/start", - new Error(`Invalid attachment id '${attachment.id}'.`), - ); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Invalid attachment id '${attachment.id}'.`, + }); } const bytes = yield* fileSystem.readFile(attachmentPath).pipe( Effect.mapError( @@ -1466,48 +1505,62 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( { concurrency: 1 }, ); - return yield* Effect.tryPromise({ - try: () => { - const managerInput = { - threadId: input.threadId, - ...(input.input !== undefined ? { input: input.input } : {}), - ...(input.modelSelection?.provider === "codex" - ? { model: input.modelSelection.model } - : {}), - ...(input.modelSelection?.provider === "codex" && - input.modelSelection.options?.reasoningEffort !== undefined - ? { effort: input.modelSelection.options.reasoningEffort } - : {}), - ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode - ? { serviceTier: "fast" } - : {}), - ...(input.interactionMode !== undefined - ? { interactionMode: input.interactionMode } - : {}), - ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), - }; - return manager.sendTurn(managerInput); - }, - catch: (cause) => toRequestError(input.threadId, "turn/start", cause), - }).pipe( - Effect.map((result) => ({ - ...result, - threadId: input.threadId, - })), - ); + const session = yield* requireSession(input.threadId); + const reasoningEffort = + input.modelSelection?.instanceId === boundInstanceId + ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") + : undefined; + const fastMode = + input.modelSelection?.instanceId === boundInstanceId + ? getModelSelectionBooleanOptionValue(input.modelSelection, "fastMode") + : undefined; + return yield* session.runtime + .sendTurn({ + ...(input.input !== undefined ? { input: input.input } : {}), + ...(input.modelSelection?.instanceId === boundInstanceId + ? { model: input.modelSelection.model } + : {}), + ...(reasoningEffort + ? { + effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, + } + : {}), + ...(fastMode === true ? { serviceTier: "fast" } : {}), + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), + }) + .pipe(Effect.mapError((cause) => mapCodexRuntimeError(input.threadId, "turn/start", cause))); + }); + + const requireSession = Effect.fn("requireSession")(function* (threadId: ThreadId) { + const session = sessions.get(threadId); + if (!session || session.stopped) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return session; }); const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => - Effect.tryPromise({ - try: () => manager.interruptTurn(threadId, turnId), - catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), - }); + requireSession(threadId).pipe( + Effect.flatMap((session) => session.runtime.interruptTurn(turnId)), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(threadId, "turn/interrupt", cause), + ), + ); const readThread: CodexAdapterShape["readThread"] = (threadId) => - Effect.tryPromise({ - try: () => manager.readThread(threadId), - catch: (cause) => toRequestError(threadId, "thread/read", cause), - }).pipe( + requireSession(threadId).pipe( + Effect.flatMap((session) => session.runtime.readThread), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(threadId, "thread/read", cause), + ), Effect.map((snapshot) => ({ threadId, turns: snapshot.turns, @@ -1525,10 +1578,13 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); } - return Effect.tryPromise({ - try: () => manager.rollbackThread(threadId, numTurns), - catch: (cause) => toRequestError(threadId, "thread/rollback", cause), - }).pipe( + return requireSession(threadId).pipe( + Effect.flatMap((session) => session.runtime.rollbackThread(numTurns)), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(threadId, "thread/rollback", cause), + ), Effect.map((snapshot) => ({ threadId, turns: snapshot.turns, @@ -1537,38 +1593,28 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }; const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => - Effect.tryPromise({ - try: () => manager.respondToRequest(threadId, requestId, decision), - catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), - }); + requireSession(threadId).pipe( + Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(threadId, "item/requestApproval/decision", cause), + ), + ); const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( threadId, requestId, answers, ) => - Effect.tryPromise({ - try: () => manager.respondToUserInput(threadId, requestId, answers), - catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), - }); - - const stopSession: CodexAdapterShape["stopSession"] = (threadId) => - Effect.sync(() => { - manager.stopSession(threadId); - }); - - const listSessions: CodexAdapterShape["listSessions"] = () => - Effect.sync(() => manager.listSessions()); - - const hasSession: CodexAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => manager.hasSession(threadId)); - - const stopAll: CodexAdapterShape["stopAll"] = () => - Effect.sync(() => { - manager.stopAll(); - }); - - const runtimeEventQueue = yield* Queue.unbounded(); + requireSession(threadId).pipe( + Effect.flatMap((session) => session.runtime.respondToUserInput(requestId, answers)), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(threadId, "item/tool/requestUserInput", cause), + ), + ); const writeNativeEvent = Effect.fn("writeNativeEvent")(function* (event: ProviderEvent) { if (!nativeEventLogger) { @@ -1577,38 +1623,51 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( yield* nativeEventLogger.write(event, event.threadId); }); - const registerListener = Effect.fn("registerListener")(function* () { - const services = yield* Effect.context(); - const listenerEffect = Effect.fn("listener")(function* (event: ProviderEvent) { - yield* writeNativeEvent(event); - const runtimeEvents = mapToRuntimeEvents(event, event.threadId); - if (runtimeEvents.length === 0) { - yield* Effect.logDebug("ignoring unhandled Codex provider event", { - method: event.method, - threadId: event.threadId, - turnId: event.turnId, - itemId: event.itemId, - }); + const stopSessionInternal = Effect.fn("stopSessionInternal")(function* ( + session: CodexAdapterSessionContext, + ) { + if (session.stopped) { + return; + } + session.stopped = true; + sessions.delete(session.threadId); + yield* session.runtime.close.pipe(Effect.ignore); + yield* Effect.ignore(Scope.close(session.scope, Exit.void)); + yield* Fiber.interrupt(session.eventFiber).pipe(Effect.ignore); + }); + + const stopSession: CodexAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const session = sessions.get(threadId); + if (!session) { return; } - yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); + yield* stopSessionInternal(session); }); - const listener = (event: ProviderEvent) => - listenerEffect(event).pipe(Effect.runPromiseWith(services)); - manager.on("event", listener); - return listener; - }); - const unregisterListener = Effect.fn("unregisterListener")(function* ( - listener: (event: ProviderEvent) => Promise, - ) { - yield* Effect.sync(() => { - manager.off("event", listener); - }); - yield* Queue.shutdown(runtimeEventQueue); - }); + const listSessions: CodexAdapterShape["listSessions"] = () => + Effect.forEach( + Array.from(sessions.values()).filter((session) => !session.stopped), + (session) => session.runtime.getSession, + { concurrency: 1 }, + ); - yield* Effect.acquireRelease(registerListener(), unregisterListener); + const hasSession: CodexAdapterShape["hasSession"] = (threadId) => + Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped)); + + const stopAll: CodexAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { + concurrency: 1, + discard: true, + }).pipe(Effect.asVoid); + + yield* Effect.acquireRelease(Effect.void, () => + stopAll().pipe( + Effect.andThen(Queue.shutdown(runtimeEventQueue)), + Effect.andThen(managedNativeEventLogger?.close() ?? Effect.void), + Effect.ignore, + ), + ); return { provider: PROVIDER, @@ -1632,8 +1691,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } satisfies CodexAdapterShape; }); -export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); - -export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { - return Layer.effect(CodexAdapter, makeCodexAdapter(options)); -} +// NOTE: the old `CodexAdapterLive` / `makeCodexAdapterLive` singleton Layer +// exports have been removed as part of the per-instance-driver refactor. +// `makeCodexAdapter(codexConfig, options?)` is now invoked directly by +// `CodexDriver.create()` for each configured instance; downstream consumers +// (server bootstrap, integration harness, this module's tests) will be +// migrated to the registry in a follow-up pass. diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 421621c9699..178450fb7fd 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,372 +1,433 @@ -import * as OS from "node:os"; +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 Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Types from "effect/Types"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as CodexClient from "effect-codex-app-server/client"; +import * as CodexSchema from "effect-codex-app-server/schema"; +import * as CodexErrors from "effect-codex-app-server/errors"; + import type { - ModelCapabilities, CodexSettings, ServerProvider, + ServerProviderState, + ModelCapabilities, ServerProviderModel, - ServerProviderAuth, ServerProviderSkill, - ServerProviderState, } from "@t3tools/contracts"; -import { - Cache, - Duration, - Effect, - Equal, - FileSystem, - Layer, - Option, - Path, - Result, - Stream, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ServerSettingsError } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { + AUTH_PROBE_TIMEOUT_MS, buildServerProvider, - DEFAULT_TIMEOUT_MS, - detailFromResult, - extractAuthBoolean, - isCommandMissingCause, - parseGenericCliVersion, - providerModelsFromSettings, - spawnAndCollect, - type CommandResult, -} from "../providerSnapshot"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; -import { - formatCodexCliUpgradeMessage, - isCodexCliVersionSupported, - parseCodexCliVersion, -} from "../codexCliVersion"; -import { - adjustCodexModelsForAccount, - codexAuthSubLabel, - codexAuthSubType, - type CodexAccountSnapshot, -} from "../codexAccount"; -import { probeCodexDiscovery } from "../codexAppServer"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ServerSettingsService } from "../../serverSettings"; -import { ServerSettingsError } from "@t3tools/contracts"; + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; +import packageJson from "../../../package.json" with { type: "json" }; +const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); + +const CODEX_PRESENTATION = { + displayName: "Codex", + showInteractionModeToggle: true, +} as const; + +export interface CodexAppServerProviderSnapshot { + readonly account: CodexSchema.V2GetAccountResponse; + readonly version: string | undefined; + readonly models: ReadonlyArray; + readonly skills: ReadonlyArray; +} -const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], +const REASONING_EFFORT_LABELS: Record = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", }; -const PROVIDER = "codex" as const; -const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); -const BUILT_IN_MODELS: ReadonlyArray = [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.4-mini", - name: "GPT-5.4 Mini", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2", - name: "GPT-5.2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - -export function getCodexModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); - return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? - DEFAULT_CODEX_MODEL_CAPABILITIES +function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { + if (!account) return undefined; + if (account.type === "apiKey") return "OpenAI API Key"; + if (account.type === "amazonBedrock") return "Amazon Bedrock"; + if (account.type !== "chatgpt") return undefined; + + switch (account.planType) { + case "free": + return "ChatGPT Free Subscription"; + case "go": + return "ChatGPT Go Subscription"; + case "plus": + return "ChatGPT Plus Subscription"; + case "pro": + return "ChatGPT Pro 20x Subscription"; + case "prolite": + return "ChatGPT Pro 5x Subscription"; + case "team": + return "ChatGPT Team Subscription"; + case "self_serve_business_usage_based": + case "business": + return "ChatGPT Business Subscription"; + case "enterprise_cbp_usage_based": + case "enterprise": + return "ChatGPT Enterprise Subscription"; + case "edu": + return "ChatGPT Edu Subscription"; + case "unknown": + return "ChatGPT Subscription"; + default: + account.planType satisfies never; + return undefined; + } +} + +function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { + if (!account || account.type !== "chatgpt") return undefined; + return account.email; +} + +function mapCodexModelCapabilities( + model: CodexSchema.V2ModelListResponse__Model, +): ModelCapabilities { + const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => + reasoningEffort === model.defaultReasoningEffort + ? { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + isDefault: true, + } + : { + id: reasoningEffort, + label: REASONING_EFFORT_LABELS[reasoningEffort], + }, ); + const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; + const supportsFastMode = (model.additionalSpeedTiers ?? []).includes("fast"); + return createModelCapabilities({ + optionDescriptors: [ + ...(reasoningOptions.length > 0 + ? [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: reasoningOptions, + ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), + }, + ] + : []), + ...(supportsFastMode + ? [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean" as const, + }, + ] + : []), + ], + }); } -export function parseAuthStatusFromOutput(result: CommandResult): { - readonly status: Exclude; - readonly auth: Pick; - readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); +const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => { + // Capitalize 'gpt' to 'GPT-' and capitalize any letter following a dash + return model.displayName + .replace(/^gpt/i, "GPT") // Handle start with 'gpt' or 'GPT' + .replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase()); +}; - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { - return { - status: "warning", - auth: { status: "unknown" }, - message: "Codex CLI authentication status command is unavailable in this Codex version.", - }; - } +function parseCodexModelListResponse( + response: CodexSchema.V2ModelListResponse, +): ReadonlyArray { + return response.data.map((model) => ({ + slug: model.model, + name: toDisplayName(model), + isCustom: false, + capabilities: mapCodexModelCapabilities(model), + })); +} - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `codex login`") || - lowerOutput.includes("run codex login") - ) { - return { - status: "error", - auth: { status: "unauthenticated" }, - message: "Codex CLI is not authenticated. Run `codex login` and try again.", - }; +function appendCustomCodexModels( + models: ReadonlyArray, + customModels: ReadonlyArray, +): ReadonlyArray { + if (customModels.length === 0) { + return models; } - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + const seen = new Set(models.map((model) => model.slug)); + const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; + const customEntries: ServerProviderModel[] = []; + for (const rawModel of customModels) { + const slug = rawModel.trim(); + if (!slug || seen.has(slug)) { + continue; } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", auth: { status: "authenticated" } }; - } - if (parsedAuth.auth === false) { - return { - status: "error", - auth: { status: "unauthenticated" }, - message: "Codex CLI is not authenticated. Run `codex login` and try again.", - }; + seen.add(slug); + customEntries.push({ + slug, + name: slug, + isCustom: true, + capabilities: fallbackCapabilities, + }); } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - auth: { status: "unknown" }, - message: - "Could not verify Codex authentication status from JSON output (missing auth marker).", + return customEntries.length === 0 ? models : [...models, ...customEntries]; +} + +function parseCodexSkillsListResponse( + response: CodexSchema.V2SkillsListResponse, + cwd: string, +): ReadonlyArray { + const matchingEntry = response.data.find((entry) => entry.cwd === cwd); + const skills = matchingEntry + ? matchingEntry.skills + : response.data.flatMap((entry) => entry.skills); + + return skills.map((skill) => { + const shortDescription = + skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; + + const parsedSkill: Types.Mutable = { + name: skill.name, + path: skill.path, + enabled: skill.enabled, }; - } - if (result.code === 0) { - return { status: "ready", auth: { status: "authenticated" } }; - } - const detail = detailFromResult(result); - return { - status: "warning", - auth: { status: "unknown" }, - message: detail - ? `Could not verify Codex authentication status. ${detail}` - : "Could not verify Codex authentication status.", - }; -} + if (skill.description) { + parsedSkill.description = skill.description; + } + if (skill.scope) { + parsedSkill.scope = skill.scope; + } + if (skill.interface?.displayName) { + parsedSkill.displayName = skill.interface.displayName; + } + if (shortDescription) { + parsedSkill.shortDescription = shortDescription; + } -export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvider")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const settingsService = yield* ServerSettingsService; - const codexHome = yield* settingsService.getSettings.pipe( - Effect.map( - (settings) => - settings.providers.codex.homePath || - process.env.CODEX_HOME || - path.join(OS.homedir(), ".codex"), - ), - ); - const configPath = path.join(codexHome, "config.toml"); + return parsedSkill; + }); +} - const content = yield* fileSystem - .readFileString(configPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (content === undefined) { - return undefined; - } +const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( + client: CodexClient.CodexAppServerClientShape, +) { + const models: ServerProviderModel[] = []; + let cursor: string | null | undefined = undefined; - let inTopLevel = true; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - if (trimmed.startsWith("[")) { - inTopLevel = false; - continue; - } - if (!inTopLevel) continue; + do { + const response: CodexSchema.V2ModelListResponse = yield* client.request( + "model/list", + cursor ? { cursor } : {}, + ); + models.push(...parseCodexModelListResponse(response)); + cursor = response.nextCursor; + } while (cursor); - const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); - if (match) return match[1]; - } - return undefined; + return models; }); -export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( - Effect.map((provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider)), - Effect.orElseSucceed(() => false), -); - -const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { + return { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: packageJson.version, + }, + capabilities: { + experimentalApi: true, + }, + }; +} -const probeCodexCapabilities = (input: { +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; readonly cwd: string; -}) => - Effect.tryPromise((signal) => probeCodexDiscovery({ ...input, signal })).pipe( - Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), - Effect.result, - Effect.map((result) => { - if (Result.isFailure(result)) return undefined; - return Option.isSome(result.success) ? result.success.value : undefined; + readonly customModels?: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; +}) { + // `~` is not shell-expanded when env vars are set via `child_process.spawn`, + // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip + // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const clientContext = yield* Layer.build( + CodexClient.layerCommand({ + command: input.binaryPath, + args: ["app-server"], + cwd: input.cwd, + env: { + ...(input.environment ?? process.env), + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }, }), ); - -const runCodexCommand = Effect.fn("runCodexCommand")(function* (args: ReadonlyArray) { - const settingsService = yield* ServerSettingsService; - const codexSettings = yield* settingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.codex), + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), ); - const command = ChildProcess.make(codexSettings.binaryPath, [...args], { - shell: process.platform === "win32", - env: { - ...process.env, - ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + + const initialize = yield* client.request("initialize", { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, }, }); - return yield* spawnAndCollect(codexSettings.binaryPath, command); + yield* client.notify("initialized", undefined); + + // Extract the version string after the first '/' in userAgent, up to the next space or the end + const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); + const version = versionMatch ? versionMatch[1] : undefined; + + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return { + account: accountResponse, + version, + models: appendCustomCodexModels([], input.customModels ?? []), + skills: [], + } satisfies CodexAppServerProviderSnapshot; + } + + const [skillsResponse, models] = yield* Effect.all( + [ + client.request("skills/list", { + cwds: [input.cwd], + }), + requestAllCodexModels(client), + ], + { concurrency: "unbounded" }, + ); + + return { + account: accountResponse, + version, + models: appendCustomCodexModels(models, input.customModels ?? []), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + } satisfies CodexAppServerProviderSnapshot; }); +const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => + codexSettings.customModels + .map((model) => model.trim()) + .filter((model, index, models) => model.length > 0 && models.indexOf(model) === index) + .map((model) => ({ + slug: model, + name: model, + isCustom: true, + capabilities: null, + })); + +const makePendingCodexProvider = ( + codexSettings: CodexSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = emptyCodexModelsFromSettings(codexSettings); + + if (!codexSettings.enabled) { + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: false, + checkedAt, + models, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CODEX_PRESENTATION, + enabled: true, + checkedAt, + models, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex provider status has not been checked in this session yet.", + }, + }); + }); + +function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { + readonly status: Exclude; + readonly auth: ServerProvider["auth"]; + readonly message?: string; +} { + const authLabel = codexAccountAuthLabel(account.account); + const authEmail = codexAccountEmail(account.account); + const auth = { + status: account.account ? ("authenticated" as const) : ("unknown" as const), + ...(account.account?.type ? { type: account.account?.type } : {}), + ...(authLabel ? { label: authLabel } : {}), + ...(authEmail ? { email: authEmail } : {}), + } satisfies ServerProvider["auth"]; + + if (account.account) { + return { status: "ready", auth }; + } + + if (account.requiresOpenaiAuth) { + return { + status: "error", + auth: { status: "unauthenticated" }, + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + + return { status: "ready", auth }; +} + export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( - resolveAccount?: (input: { - readonly binaryPath: string; - readonly homePath?: string; - }) => Effect.Effect, - resolveSkills?: (input: { + codexSettings: CodexSettings, + probe: (input: { readonly binaryPath: string; readonly homePath?: string; readonly cwd: string; - }) => Effect.Effect | undefined>, + readonly customModels: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect< + CodexAppServerProviderSnapshot, + CodexErrors.CodexAppServerError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + > = probeCodexAppServerProvider, + environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return< - ServerProvider, + ServerProviderDraft, ServerSettingsError, - | ChildProcessSpawner.ChildProcessSpawner - | FileSystem.FileSystem - | Path.Path - | ServerSettingsService + ChildProcessSpawner.ChildProcessSpawner > { - const codexSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.codex), - ); - const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings( - BUILT_IN_MODELS, - PROVIDER, - codexSettings.customModels, - DEFAULT_CODEX_MODEL_CAPABILITIES, - ); + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const emptyModels = emptyCodexModelsFromSettings(codexSettings); if (!codexSettings.enabled) { return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: false, checkedAt, - models, + models: emptyModels, + skills: [], probe: { installed: false, version: null, @@ -377,232 +438,80 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); } - const versionProbe = yield* runCodexCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + const probeResult = yield* probe({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + cwd: process.cwd(), + customModels: codexSettings.customModels, + environment, + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), Effect.result, ); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; + if (Result.isFailure(probeResult)) { + const error = probeResult.failure; + const installed = !isCodexAppServerSpawnError(error); return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, - models, + models: emptyModels, + skills: [], probe: { - installed: !isCommandMissingCause(error), + installed, version: null, status: "error", auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Codex CLI (`codex`) is not installed or not on PATH." - : `Failed to execute Codex CLI health check: ${error.message}.`, + message: installed + ? `Codex app-server provider probe failed: ${error.message}.` + : "Codex CLI (`codex`) is not installed or not on PATH.", }, }); } - if (Option.isNone(versionProbe.success)) { + if (Option.isNone(probeResult.success)) { return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, - models, + models: emptyModels, + skills: [], probe: { installed: true, version: null, status: "error", auth: { status: "unknown" }, - message: "Codex CLI is installed but failed to run. Timed out while running command.", - }, - }); - } - - const version = versionProbe.success.value; - const parsedVersion = - parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ?? - parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { - const detail = detailFromResult(version); - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "error", - auth: { status: "unknown" }, - message: detail - ? `Codex CLI is installed but failed to run. ${detail}` - : "Codex CLI is installed but failed to run.", - }, - }); - } - - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "error", - auth: { status: "unknown" }, - message: formatCodexCliUpgradeMessage(parsedVersion), + message: "Timed out while checking Codex app-server provider status.", }, }); } - const skills = - (resolveSkills - ? yield* resolveSkills({ - binaryPath: codexSettings.binaryPath, - homePath: codexSettings.homePath, - cwd: process.cwd(), - }).pipe(Effect.orElseSucceed(() => undefined)) - : undefined) ?? []; - - if (yield* hasCustomModelProvider) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - skills, - probe: { - installed: true, - version: parsedVersion, - status: "ready", - auth: { status: "unknown" }, - message: "Using a custom Codex model provider; OpenAI login check skipped.", - }, - }); - } + const snapshot = probeResult.success.value; + const accountStatus = accountProbeStatus(snapshot.account); - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - const account = resolveAccount - ? yield* resolveAccount({ - binaryPath: codexSettings.binaryPath, - homePath: codexSettings.homePath, - }) - : undefined; - const resolvedModels = adjustCodexModelsForAccount(models, account); - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models: resolvedModels, - skills, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - auth: { status: "unknown" }, - message: `Could not verify Codex authentication status: ${error.message}.`, - }, - }); - } - - if (Option.isNone(authProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models: resolvedModels, - skills, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - auth: { status: "unknown" }, - message: "Could not verify Codex authentication status. Timed out while running command.", - }, - }); - } - - const parsed = parseAuthStatusFromOutput(authProbe.success.value); - const authType = codexAuthSubType(account); - const authLabel = codexAuthSubLabel(account); return buildServerProvider({ - provider: PROVIDER, + presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, - models: resolvedModels, - skills, + models: snapshot.models, + skills: snapshot.skills, probe: { installed: true, - version: parsedVersion, - status: parsed.status, - auth: { - ...parsed.auth, - ...(authType ? { type: authType } : {}), - ...(authLabel ? { label: authLabel } : {}), - }, - ...(parsed.message ? { message: parsed.message } : {}), + version: snapshot.version ?? null, + status: accountStatus.status, + auth: accountStatus.auth, + ...(accountStatus.message ? { message: accountStatus.message } : {}), }, }); }); -export const CodexProviderLive = Layer.effect( - CodexProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const accountProbeCache = yield* Cache.make({ - capacity: 4, - timeToLive: Duration.minutes(5), - lookup: (key: string) => { - const [binaryPath, homePath, cwd] = JSON.parse(key) as [string, string | undefined, string]; - return probeCodexCapabilities({ - binaryPath, - cwd, - ...(homePath ? { homePath } : {}), - }); - }, - }); - - const getDiscovery = (input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly cwd: string; - }) => - Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath, input.cwd])); - - const checkProvider = checkCodexProviderStatus( - (input) => - getDiscovery({ - ...input, - cwd: process.cwd(), - }).pipe(Effect.map((discovery) => discovery?.account)), - (input) => getDiscovery(input).pipe(Effect.map((discovery) => discovery?.skills)), - ).pipe( - Effect.provideService(ServerSettingsService, serverSettings), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - - return yield* makeManagedServerProvider({ - getSettings: serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.codex), - Effect.orDie, - ), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.codex), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - checkProvider, - }); - }), -); +// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of +// the per-instance-driver refactor. `CodexDriver.create()` builds a managed +// snapshot per instance (each with its own `CodexSettings`) and hands the +// resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. +// +// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are +// re-exported for use by `CodexDriver`. +export { makePendingCodexProvider }; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts new file mode 100644 index 00000000000..82546621d32 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; + +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { describe, it } from "vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import * as CodexRpc from "effect-codex-app-server/rpc"; + +import { + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, +} from "../CodexDeveloperInstructions.ts"; +import { + buildTurnStartParams, + isRecoverableThreadResumeError, + openCodexThread, +} from "./CodexSessionRuntime.ts"; +const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); + +function makeThreadOpenResponse( + threadId: string, +): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { + return { + cwd: "/tmp/project", + model: "gpt-5.3-codex", + modelProvider: "openai", + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "danger-full-access" }, + thread: { + id: threadId, + createdAt: "2026-04-18T00:00:00.000Z", + source: { session: "cli" }, + turns: [], + status: { + state: "idle", + activeFlags: [], + }, + }, + } as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"]; +} + +describe("buildTurnStartParams", () => { + it("includes plan collaboration mode when requested", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Make a plan", + model: "gpt-5.3-codex", + effort: "medium", + interactionMode: "plan", + }), + ); + + assert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "never", + sandboxPolicy: { + type: "dangerFullAccess", + }, + input: [ + { + type: "text", + text: "Make a plan", + }, + ], + model: "gpt-5.3-codex", + effort: "medium", + collaborationMode: { + mode: "plan", + settings: { + model: "gpt-5.3-codex", + reasoning_effort: "medium", + developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + }, + }, + }); + }); + + it("includes default collaboration mode and image attachments", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "auto-accept-edits", + prompt: "Implement it", + model: "gpt-5.3-codex", + interactionMode: "default", + attachments: [ + { + type: "image", + url: "data:image/png;base64,abc", + }, + ], + }), + ); + + assert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + }, + input: [ + { + type: "text", + text: "Implement it", + }, + { + type: "image", + url: "data:image/png;base64,abc", + }, + ], + model: "gpt-5.3-codex", + collaborationMode: { + mode: "default", + settings: { + model: "gpt-5.3-codex", + reasoning_effort: "medium", + developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + }, + }, + }); + }); + + it("omits collaboration mode when interaction mode is absent", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "approval-required", + prompt: "Review", + }), + ); + + assert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "untrusted", + sandboxPolicy: { + type: "readOnly", + }, + input: [ + { + type: "text", + text: "Review", + }, + ], + }); + }); +}); + +describe("isRecoverableThreadResumeError", () => { + it("matches missing thread errors", () => { + assert.equal( + isRecoverableThreadResumeError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "Thread does not exist", + }), + ), + true, + ); + }); + + it("ignores non-recoverable resume errors", () => { + assert.equal( + isRecoverableThreadResumeError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "Permission denied", + }), + ), + false, + ); + }); + + it("ignores unrelated missing-resource errors that do not mention threads", () => { + assert.equal( + isRecoverableThreadResumeError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "Config file not found", + }), + ), + false, + ); + assert.equal( + isRecoverableThreadResumeError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "Model does not exist", + }), + ), + false, + ); + }); +}); + +describe("openCodexThread", () => { + it("falls back to thread/start when resume fails recoverably", async () => { + const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; + const started = makeThreadOpenResponse("fresh-thread"); + const client = { + request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + calls.push({ method, payload }); + if (method === "thread/resume") { + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread not found", + }), + ); + } + return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); + }, + }; + + const opened = await Effect.runPromise( + openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "stale-thread", + }), + ); + + assert.equal(opened.thread.id, "fresh-thread"); + assert.deepStrictEqual( + calls.map((call) => call.method), + ["thread/resume", "thread/start"], + ); + }); + + it("propagates non-recoverable resume failures", async () => { + const client = { + request: ( + method: M, + _payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + if (method === "thread/resume") { + return Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "timed out waiting for server", + }), + ); + } + return Effect.succeed( + makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], + ); + }, + }; + + await assert.rejects( + Effect.runPromise( + openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "stale-thread", + }), + ), + (error: unknown) => + isCodexAppServerRequestError(error) && + error.errorMessage === "timed out waiting for server", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts new file mode 100644 index 00000000000..7f71ef46b2c --- /dev/null +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -0,0 +1,1371 @@ +import { + ApprovalRequestId, + DEFAULT_MODEL, + EventId, + ProviderDriverKind, + ProviderItemId, + type ProviderInstanceId, + type ProviderApprovalDecision, + type ProviderEvent, + type ProviderInteractionMode, + type ProviderRequestKind, + type ProviderSession, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeMode, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SchemaIssue from "effect/SchemaIssue"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as CodexClient from "effect-codex-app-server/client"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import * as CodexRpc from "effect-codex-app-server/rpc"; +import * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; +import { + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, +} from "../CodexDeveloperInstructions.ts"; +const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); + +const PROVIDER = ProviderDriverKind.make("codex"); + +const ANSI_ESCAPE_CHAR = String.fromCharCode(27); +const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); +const CODEX_STDERR_LOG_REGEX = + /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/; +const BENIGN_ERROR_LOG_SNIPPETS = [ + "state db missing rollout path for thread", + "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", +]; +const CODEX_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; +const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ + "not found", + "missing thread", + "no such thread", + "unknown thread", + "does not exist", +]; + +export const CodexResumeCursorSchema = Schema.Struct({ + threadId: Schema.String, +}); +const CodexUserInputAnswerObject = Schema.Struct({ + answers: Schema.Array(Schema.String), +}); +const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); +const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject); + +// TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated +// `V2TurnStartParams` schema includes `collaborationMode` directly. +const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( + Schema.fieldsAssign({ + collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), + }), +); +const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( + CodexTurnStartParamsWithCollaborationMode, +); + +export type CodexTurnStartParamsWithCollaborationMode = + typeof CodexTurnStartParamsWithCollaborationMode.Type; +const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); + +export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; +type CodexServiceTier = NonNullable; +type CodexThreadItem = + | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number] + | EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number]; + +export interface CodexSessionRuntimeOptions { + readonly threadId: ThreadId; + readonly providerInstanceId?: ProviderInstanceId; + readonly binaryPath: string; + readonly homePath?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly cwd: string; + readonly runtimeMode: RuntimeMode; + readonly model?: string; + readonly serviceTier?: CodexServiceTier | undefined; + readonly resumeCursor?: CodexResumeCursor; +} + +export interface CodexSessionRuntimeSendTurnInput { + readonly input?: string; + readonly attachments?: ReadonlyArray<{ + readonly type: "image"; + readonly url: string; + }>; + readonly model?: string; + readonly serviceTier?: CodexServiceTier | undefined; + readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; + readonly interactionMode?: ProviderInteractionMode; +} + +export interface CodexThreadTurnSnapshot { + readonly id: TurnId; + readonly items: ReadonlyArray; +} + +export interface CodexThreadSnapshot { + readonly threadId: string; + readonly turns: ReadonlyArray; +} + +export interface CodexSessionRuntimeShape { + readonly start: () => Effect.Effect; + readonly getSession: Effect.Effect; + readonly sendTurn: ( + input: CodexSessionRuntimeSendTurnInput, + ) => Effect.Effect; + readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; + readonly readThread: Effect.Effect; + readonly rollbackThread: ( + numTurns: number, + ) => Effect.Effect; + readonly respondToRequest: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Effect.Effect; + readonly respondToUserInput: ( + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + ) => Effect.Effect; + readonly events: Stream.Stream; + readonly close: Effect.Effect; +} + +export type CodexSessionRuntimeError = + | CodexErrors.CodexAppServerError + | CodexSessionRuntimePendingApprovalNotFoundError + | CodexSessionRuntimePendingUserInputNotFoundError + | CodexSessionRuntimeInvalidUserInputAnswersError + | CodexSessionRuntimeThreadIdMissingError; + +export class CodexSessionRuntimePendingApprovalNotFoundError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimePendingApprovalNotFoundError", + { + requestId: Schema.String, + }, +) { + override get message(): string { + return `Unknown pending Codex approval request: ${this.requestId}`; + } +} + +export class CodexSessionRuntimePendingUserInputNotFoundError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimePendingUserInputNotFoundError", + { + requestId: Schema.String, + }, +) { + override get message(): string { + return `Unknown pending Codex user input request: ${this.requestId}`; + } +} + +export class CodexSessionRuntimeInvalidUserInputAnswersError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeInvalidUserInputAnswersError", + { + questionId: Schema.String, + }, +) { + override get message(): string { + return `Invalid Codex user input answers for question '${this.questionId}'`; + } +} + +export class CodexSessionRuntimeThreadIdMissingError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeThreadIdMissingError", + { + threadId: Schema.String, + }, +) { + override get message(): string { + return `Codex session is missing a provider thread id for ${this.threadId}`; + } +} + +interface PendingApproval { + readonly requestId: ApprovalRequestId; + readonly jsonRpcId: string; + readonly requestKind: ProviderRequestKind; + readonly turnId: TurnId | undefined; + readonly itemId: ProviderItemId | undefined; + readonly decision: Deferred.Deferred; +} + +interface ApprovalCorrelation { + readonly requestId: ApprovalRequestId; + readonly requestKind: ProviderRequestKind; + readonly turnId: TurnId | undefined; + readonly itemId: ProviderItemId | undefined; +} + +interface PendingUserInput { + readonly requestId: ApprovalRequestId; + readonly turnId: TurnId | undefined; + readonly itemId: ProviderItemId | undefined; + readonly answers: Deferred.Deferred; +} + +type CodexServerNotification = { + readonly [M in CodexRpc.ServerNotificationMethod]: { + readonly method: M; + readonly params: CodexRpc.ServerNotificationParamsByMethod[M]; + }; +}[CodexRpc.ServerNotificationMethod]; + +function makeCodexServerNotification( + method: M, + params: CodexRpc.ServerNotificationParamsByMethod[M], +): CodexServerNotification { + return { method, params } as CodexServerNotification; +} + +function normalizeCodexModelSlug( + model: string | undefined | null, + preferredId?: string, +): string | undefined { + const normalized = normalizeModelSlug(model); + if (!normalized) { + return undefined; + } + if (preferredId?.endsWith("-codex") && preferredId !== normalized) { + return preferredId; + } + return normalized; +} + +function readResumeCursorThreadId( + resumeCursor: ProviderSession["resumeCursor"], +): string | undefined { + return isCodexResumeCursorSchema(resumeCursor) ? resumeCursor.threadId : undefined; +} + +function runtimeModeToThreadConfig(input: RuntimeMode): { + readonly approvalPolicy: EffectCodexSchema.V2ThreadStartParams__AskForApproval; + readonly sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode; +} { + switch (input) { + case "approval-required": + return { + approvalPolicy: "untrusted", + sandbox: "read-only", + }; + case "auto-accept-edits": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + }; + case "full-access": + default: + return { + approvalPolicy: "never", + sandbox: "danger-full-access", + }; + } +} + +function buildThreadStartParams(input: { + readonly cwd: string; + readonly runtimeMode: RuntimeMode; + readonly model: string | undefined; + readonly serviceTier: CodexServiceTier | undefined; +}): EffectCodexSchema.V2ThreadStartParams { + const config = runtimeModeToThreadConfig(input.runtimeMode); + return { + cwd: input.cwd, + approvalPolicy: config.approvalPolicy, + sandbox: config.sandbox, + ...(input.model ? { model: input.model } : {}), + ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + }; +} + +function runtimeModeToTurnSandboxPolicy( + input: RuntimeMode, +): EffectCodexSchema.V2TurnStartParams__SandboxPolicy { + switch (input) { + case "approval-required": + return { + type: "readOnly", + }; + case "auto-accept-edits": + return { + type: "workspaceWrite", + }; + case "full-access": + default: + return { + type: "dangerFullAccess", + }; + } +} + +function buildCodexCollaborationMode(input: { + readonly interactionMode?: ProviderInteractionMode; + readonly model?: string; + readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; +}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { + if (input.interactionMode === undefined) { + return undefined; + } + const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; + return { + mode: input.interactionMode, + settings: { + model, + reasoning_effort: input.effort ?? "medium", + developer_instructions: + input.interactionMode === "plan" + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + }, + }; +} + +export function buildTurnStartParams(input: { + readonly threadId: string; + readonly runtimeMode: RuntimeMode; + readonly prompt?: string; + readonly attachments?: ReadonlyArray<{ + readonly type: "image"; + readonly url: string; + }>; + readonly model?: string; + readonly serviceTier?: CodexServiceTier; + readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; + readonly interactionMode?: ProviderInteractionMode; +}): Effect.Effect< + CodexTurnStartParamsWithCollaborationMode, + CodexErrors.CodexAppServerProtocolParseError +> { + const turnInput: Array = []; + if (input.prompt) { + turnInput.push({ + type: "text", + text: input.prompt, + }); + } + for (const attachment of input.attachments ?? []) { + turnInput.push(attachment); + } + + const config = runtimeModeToThreadConfig(input.runtimeMode); + const collaborationMode = buildCodexCollaborationMode({ + ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.effort ? { effort: input.effort } : {}), + }); + + return decodeCodexTurnStartParamsWithCollaborationMode({ + threadId: input.threadId, + input: turnInput, + approvalPolicy: config.approvalPolicy, + sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), + ...(input.model ? { model: input.model } : {}), + ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.effort ? { effort: input.effort } : {}), + ...(collaborationMode ? { collaborationMode } : {}), + }).pipe( + Effect.mapError((error) => toProtocolParseError("Invalid turn/start request payload", error)), + ); +} + +function classifyCodexStderrLine(rawLine: string): { readonly message: string } | null { + const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); + if (!line) { + return null; + } + + const match = line.match(CODEX_STDERR_LOG_REGEX); + if (match) { + const level = match[1]; + if (level && level !== "ERROR") { + return null; + } + if (BENIGN_ERROR_LOG_SNIPPETS.some((snippet) => line.includes(snippet))) { + return null; + } + } + + return { message: line }; +} + +export function isRecoverableThreadResumeError(error: unknown): boolean { + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); + if (!message.includes("thread")) { + return false; + } + return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); +} + +type CodexThreadOpenResponse = + | CodexRpc.ClientRequestResponsesByMethod["thread/start"] + | CodexRpc.ClientRequestResponsesByMethod["thread/resume"]; + +type CodexThreadOpenMethod = "thread/start" | "thread/resume"; + +interface CodexThreadOpenClient { + readonly request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => Effect.Effect; +} + +export const openCodexThread = (input: { + readonly client: CodexThreadOpenClient; + readonly threadId: ThreadId; + readonly runtimeMode: RuntimeMode; + readonly cwd: string; + readonly requestedModel: string | undefined; + readonly serviceTier: CodexServiceTier | undefined; + readonly resumeThreadId: string | undefined; +}): Effect.Effect => { + const resumeThreadId = input.resumeThreadId; + const startParams = buildThreadStartParams({ + cwd: input.cwd, + runtimeMode: input.runtimeMode, + model: input.requestedModel, + serviceTier: input.serviceTier, + }); + + if (resumeThreadId === undefined) { + return input.client.request("thread/start", startParams); + } + + return input.client + .request("thread/resume", { + threadId: resumeThreadId, + ...startParams, + }) + .pipe( + Effect.catchIf(isRecoverableThreadResumeError, (error) => + Effect.logWarning("codex app-server thread resume fell back to fresh start", { + threadId: input.threadId, + requestedRuntimeMode: input.runtimeMode, + resumeThreadId, + recoverable: true, + cause: error.message, + }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), + ), + ); +}; + +function readNotificationThreadId(notification: CodexServerNotification): string | undefined { + switch (notification.method) { + case "thread/started": + return notification.params.thread.id; + case "error": + case "thread/status/changed": + case "thread/archived": + case "thread/unarchived": + case "thread/closed": + case "thread/name/updated": + case "thread/tokenUsage/updated": + case "turn/started": + case "hook/started": + case "turn/completed": + case "hook/completed": + case "turn/diff/updated": + case "turn/plan/updated": + case "item/started": + case "item/autoApprovalReview/started": + case "item/autoApprovalReview/completed": + case "item/completed": + case "rawResponseItem/completed": + case "item/agentMessage/delta": + case "item/plan/delta": + case "item/commandExecution/outputDelta": + case "item/commandExecution/terminalInteraction": + case "item/fileChange/outputDelta": + case "item/fileChange/patchUpdated": + case "serverRequest/resolved": + case "item/mcpToolCall/progress": + case "item/reasoning/summaryTextDelta": + case "item/reasoning/summaryPartAdded": + case "item/reasoning/textDelta": + case "thread/compacted": + case "thread/realtime/started": + case "thread/realtime/itemAdded": + case "thread/realtime/transcript/delta": + case "thread/realtime/transcript/done": + case "thread/realtime/outputAudio/delta": + case "thread/realtime/sdp": + case "thread/realtime/error": + case "thread/realtime/closed": + return notification.params.threadId; + default: + return undefined; + } +} + +function readRouteFields(notification: CodexServerNotification): { + readonly turnId: TurnId | undefined; + readonly itemId: ProviderItemId | undefined; +} { + switch (notification.method) { + case "thread/started": + return { + turnId: undefined, + itemId: undefined, + }; + case "turn/started": + case "turn/completed": + return { + turnId: TurnId.make(notification.params.turn.id), + itemId: undefined, + }; + case "error": + return { + turnId: TurnId.make(notification.params.turnId), + itemId: undefined, + }; + case "turn/diff/updated": + case "turn/plan/updated": + return { + turnId: TurnId.make(notification.params.turnId), + itemId: undefined, + }; + case "serverRequest/resolved": + return { + turnId: undefined, + itemId: undefined, + }; + case "item/started": + case "item/completed": + return { + turnId: TurnId.make(notification.params.turnId), + itemId: ProviderItemId.make(notification.params.item.id), + }; + case "item/agentMessage/delta": + case "item/plan/delta": + case "item/commandExecution/outputDelta": + case "item/commandExecution/terminalInteraction": + case "item/fileChange/outputDelta": + case "item/fileChange/patchUpdated": + case "item/reasoning/summaryTextDelta": + case "item/reasoning/summaryPartAdded": + case "item/reasoning/textDelta": + return { + turnId: TurnId.make(notification.params.turnId), + itemId: ProviderItemId.make(notification.params.itemId), + }; + default: + return { + turnId: undefined, + itemId: undefined, + }; + } +} + +function rememberCollabReceiverTurns( + collabReceiverTurns: Map, + notification: CodexServerNotification, + parentTurnId: TurnId | undefined, +): void { + if (!parentTurnId) { + return; + } + + if (notification.method !== "item/started" && notification.method !== "item/completed") { + return; + } + + if (notification.params.item.type !== "collabAgentToolCall") { + return; + } + + for (const receiverThreadId of notification.params.item.receiverThreadIds) { + collabReceiverTurns.set(receiverThreadId, parentTurnId); + } +} + +function shouldSuppressChildConversationNotification( + method: CodexRpc.ServerNotificationMethod, +): boolean { + return ( + method === "thread/started" || + method === "thread/status/changed" || + method === "thread/archived" || + method === "thread/unarchived" || + method === "thread/closed" || + method === "thread/compacted" || + method === "thread/name/updated" || + method === "thread/tokenUsage/updated" || + method === "turn/started" || + method === "turn/completed" || + method === "turn/plan/updated" || + method === "item/plan/delta" + ); +} + +function toCodexUserInputAnswer( + questionId: string, + value: ProviderUserInputAnswers[string], +): Effect.Effect< + EffectCodexSchema.ToolRequestUserInputResponse__ToolRequestUserInputAnswer, + CodexSessionRuntimeInvalidUserInputAnswersError +> { + if (typeof value === "string") { + return Effect.succeed({ answers: [value] }); + } + if (Array.isArray(value)) { + const answers = value.filter((entry): entry is string => typeof entry === "string"); + return Effect.succeed({ answers }); + } + if (isCodexUserInputAnswerObject(value)) { + return Effect.succeed({ answers: value.answers }); + } + return Effect.fail(new CodexSessionRuntimeInvalidUserInputAnswersError({ questionId })); +} + +function toCodexUserInputAnswers( + answers: ProviderUserInputAnswers, +): Effect.Effect< + EffectCodexSchema.ToolRequestUserInputResponse["answers"], + CodexSessionRuntimeInvalidUserInputAnswersError +> { + return Effect.forEach( + Object.entries(answers), + ([questionId, value]) => + toCodexUserInputAnswer(questionId, value).pipe( + Effect.map((answer) => [questionId, answer] as const), + ), + { concurrency: 1 }, + ).pipe(Effect.map((entries) => Object.fromEntries(entries))); +} + +function toProtocolParseError( + detail: string, + cause: Schema.SchemaError, +): CodexErrors.CodexAppServerProtocolParseError { + return new CodexErrors.CodexAppServerProtocolParseError({ + detail: `${detail}: ${formatSchemaIssue(cause.issue)}`, + cause, + }); +} + +function currentProviderThreadId(session: ProviderSession): string | undefined { + return readResumeCursorThreadId(session.resumeCursor); +} + +function updateSession( + sessionRef: Ref.Ref, + updates: Partial, +): Effect.Effect { + return Effect.gen(function* () { + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* Ref.update(sessionRef, (session) => ({ + ...session, + ...updates, + updatedAt, + })); + }); +} + +function parseThreadSnapshot( + response: EffectCodexSchema.V2ThreadReadResponse | EffectCodexSchema.V2ThreadRollbackResponse, +): CodexThreadSnapshot { + return { + threadId: response.thread.id, + turns: response.thread.turns.map((turn) => ({ + id: TurnId.make(turn.id), + items: turn.items, + })), + }; +} + +export const makeCodexSessionRuntime = ( + options: CodexSessionRuntimeOptions, +): Effect.Effect< + CodexSessionRuntimeShape, + CodexErrors.CodexAppServerError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + const events = yield* Queue.unbounded(); + const pendingApprovalsRef = yield* Ref.make(new Map()); + const approvalCorrelationsRef = yield* Ref.make(new Map()); + const pendingUserInputsRef = yield* Ref.make(new Map()); + const collabReceiverTurnsRef = yield* Ref.make(new Map()); + const closedRef = yield* Ref.make(false); + + // `~` is not shell-expanded when env vars are set via + // `child_process.spawn`; `expandHomePath` lets a configured + // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. + const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; + const env = { + ...(options.environment ?? process.env), + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const child = yield* spawner + .spawn( + ChildProcess.make(options.binaryPath, ["app-server"], { + cwd: options.cwd, + env, + forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, + shell: process.platform === "win32", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${options.binaryPath} app-server`, + cause, + }), + ), + ); + + const clientContext = yield* CodexClient.layerChildProcess(child).pipe( + Layer.build, + Effect.provideService(Scope.Scope, runtimeScope), + ); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + const serverNotifications = yield* Queue.unbounded(); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + + const sessionCreatedAt = yield* nowIso; + const initialSession = { + provider: PROVIDER, + ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), + status: "connecting", + runtimeMode: options.runtimeMode, + cwd: options.cwd, + ...(options.model ? { model: options.model } : {}), + threadId: options.threadId, + ...(options.resumeCursor !== undefined ? { resumeCursor: options.resumeCursor } : {}), + createdAt: sessionCreatedAt, + updatedAt: sessionCreatedAt, + } satisfies ProviderSession; + const sessionRef = yield* Ref.make(initialSession); + const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); + + const emitEvent = (event: Omit) => + Effect.gen(function* () { + const id = yield* Random.nextUUIDv4; + return yield* offerEvent({ + id: EventId.make(id), + provider: PROVIDER, + ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), + createdAt: yield* nowIso, + ...event, + }); + }); + const emitSessionEvent = (method: string, message: string) => + emitEvent({ + kind: "session", + threadId: options.threadId, + method, + message, + }); + + const settlePendingApprovals = (decision: ProviderApprovalDecision) => + Ref.get(pendingApprovalsRef).pipe( + Effect.flatMap((pendingApprovals) => + Effect.forEach( + Array.from(pendingApprovals.values()), + (pendingApproval) => + Deferred.succeed(pendingApproval.decision, decision).pipe(Effect.ignore), + { discard: true }, + ), + ), + ); + + const settlePendingUserInputs = (answers: ProviderUserInputAnswers) => + Ref.get(pendingUserInputsRef).pipe( + Effect.flatMap((pendingUserInputs) => + Effect.forEach( + Array.from(pendingUserInputs.values()), + (pendingUserInput) => + Deferred.succeed(pendingUserInput.answers, answers).pipe(Effect.ignore), + { discard: true }, + ), + ), + ); + + const handleRawNotification = (notification: CodexServerNotification) => + Effect.gen(function* () { + const payload = notification.params; + const route = readRouteFields(notification); + const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); + const childParentTurnId = (() => { + const providerConversationId = readNotificationThreadId(notification); + return providerConversationId + ? collabReceiverTurns.get(providerConversationId) + : undefined; + })(); + + rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); + if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) { + yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); + return; + } + + let requestId: ApprovalRequestId | undefined; + let requestKind: ProviderRequestKind | undefined; + let turnId = childParentTurnId ?? route.turnId; + let itemId = route.itemId; + + if (notification.method === "serverRequest/resolved") { + const rawRequestId = + typeof notification.params.requestId === "string" + ? notification.params.requestId + : String(notification.params.requestId); + const correlation = rawRequestId + ? (yield* Ref.get(approvalCorrelationsRef)).get(rawRequestId) + : undefined; + if (correlation) { + requestId = correlation.requestId; + requestKind = correlation.requestKind; + turnId = correlation.turnId ?? turnId; + itemId = correlation.itemId ?? itemId; + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.delete(rawRequestId); + return next; + }); + } + } + + yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: notification.method, + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + ...(requestId ? { requestId } : {}), + ...(requestKind ? { requestKind } : {}), + ...(notification.method === "item/agentMessage/delta" + ? { textDelta: notification.params.delta } + : {}), + ...(payload !== undefined ? { payload } : {}), + }); + }); + + const currentSessionProviderThreadId = Effect.map(Ref.get(sessionRef), currentProviderThreadId); + + yield* client.handleServerNotification("thread/started", (payload) => + currentSessionProviderThreadId.pipe( + Effect.flatMap((providerThreadId) => { + if (providerThreadId && payload.thread.id !== providerThreadId) { + return Effect.void; + } + return updateSession(sessionRef, { + resumeCursor: { threadId: payload.thread.id }, + }); + }), + ), + ); + + yield* client.handleServerNotification("turn/started", (payload) => + currentSessionProviderThreadId.pipe( + Effect.flatMap((providerThreadId) => { + if (providerThreadId && payload.threadId !== providerThreadId) { + return Effect.void; + } + return updateSession(sessionRef, { + status: "running", + activeTurnId: TurnId.make(payload.turn.id), + }); + }), + ), + ); + + yield* client.handleServerNotification("turn/completed", (payload) => + currentSessionProviderThreadId.pipe( + Effect.flatMap((providerThreadId) => { + if (providerThreadId && payload.threadId !== providerThreadId) { + return Effect.void; + } + const lastError = + payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error + ? payload.turn.error.message + : undefined; + return updateSession(sessionRef, { + status: payload.turn.status === "failed" ? "error" : "ready", + activeTurnId: undefined, + ...(lastError ? { lastError } : {}), + }); + }), + ), + ); + + yield* client.handleServerNotification("error", (payload) => + currentSessionProviderThreadId.pipe( + Effect.flatMap((providerThreadId) => { + const payloadThreadId = payload.threadId; + if (providerThreadId && payloadThreadId && payloadThreadId !== providerThreadId) { + return Effect.void; + } + const errorMessage = payload.error.message; + const willRetry = payload.willRetry; + return updateSession(sessionRef, { + status: willRetry ? "running" : "error", + ...(errorMessage ? { lastError: errorMessage } : {}), + }); + }), + ), + ); + + yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId: payload.approvalId ?? payload.itemId, + requestKind: "command", + turnId, + itemId, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(payload.approvalId ?? payload.itemId, { + requestId, + requestKind: "command", + turnId, + itemId, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/commandExecution/requestApproval", + requestId, + requestKind: "command", + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return { + decision: resolved, + } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; + }), + ); + + yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId: payload.itemId, + requestKind: "file-change", + turnId, + itemId, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(payload.itemId, { + requestId, + requestKind: "file-change", + turnId, + itemId, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/fileChange/requestApproval", + requestId, + requestKind: "file-change", + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return { + decision: resolved, + } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; + }), + ); + + yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const answers = yield* Deferred.make(); + + yield* Ref.update(pendingUserInputsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + turnId, + itemId, + answers, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/tool/requestUserInput", + requestId, + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); + + const resolvedAnswers = yield* Deferred.await(answers).pipe( + Effect.ensuring( + Ref.update(pendingUserInputsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + + return { + answers: yield* toCodexUserInputAnswers(resolvedAnswers).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerRequestError.invalidParams(error.message, { + questionId: error.questionId, + }), + ), + ), + } satisfies EffectCodexSchema.ToolRequestUserInputResponse; + }), + ); + + yield* client.handleUnknownServerRequest((method) => + Effect.fail(CodexErrors.CodexAppServerRequestError.methodNotFound(method)), + ); + + const registerServerNotification = (method: M) => + client.handleServerNotification(method, (params) => + Queue.offer(serverNotifications, makeCodexServerNotification(method, params)).pipe( + Effect.asVoid, + ), + ); + + yield* Effect.forEach( + Object.values( + CodexRpc.SERVER_NOTIFICATION_METHODS, + ) as ReadonlyArray, + registerServerNotification, + { concurrency: 1, discard: true }, + ); + + yield* Stream.fromQueue(serverNotifications).pipe( + Stream.runForEach(handleRawNotification), + Effect.forkIn(runtimeScope), + ); + + const stderrRemainderRef = yield* Ref.make(""); + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Ref.modify(stderrRemainderRef, (current) => { + const combined = current + chunk; + const lines = combined.split("\n"); + const remainder = lines.pop() ?? ""; + return [lines.map((line) => line.replace(/\r$/, "")), remainder] as const; + }).pipe( + Effect.flatMap((lines) => + Effect.forEach( + lines, + (line) => { + const classified = classifyCodexStderrLine(line); + if (!classified) { + return Effect.void; + } + return emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "process/stderr", + message: classified.message, + }); + }, + { discard: true }, + ), + ), + ), + ), + Effect.forkIn(runtimeScope), + ); + + yield* child.exitCode.pipe( + Effect.flatMap((exitCode) => + Ref.get(closedRef).pipe( + Effect.flatMap((closed) => { + if (closed) { + return Effect.void; + } + const nextStatus = exitCode === 0 ? "closed" : "error"; + return updateSession(sessionRef, { + status: nextStatus, + activeTurnId: undefined, + }).pipe( + Effect.andThen( + emitSessionEvent( + "session/exited", + exitCode === 0 + ? "Codex App Server exited." + : `Codex App Server exited with code ${exitCode}.`, + ), + ), + ); + }), + ), + ), + Effect.forkIn(runtimeScope), + ); + + const start = Effect.fn("CodexSessionRuntime.start")(function* () { + yield* emitSessionEvent("session/connecting", "Starting Codex App Server session."); + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + + const requestedModel = normalizeCodexModelSlug(options.model); + + const opened = yield* openCodexThread({ + client, + threadId: options.threadId, + runtimeMode: options.runtimeMode, + cwd: options.cwd, + requestedModel, + serviceTier: options.serviceTier, + resumeThreadId: readResumeCursorThreadId(options.resumeCursor), + }); + + const providerThreadId = opened.thread.id; + const session = { + ...(yield* Ref.get(sessionRef)), + status: "ready", + cwd: opened.cwd, + model: opened.model, + resumeCursor: { threadId: providerThreadId }, + updatedAt: yield* nowIso, + } satisfies ProviderSession; + yield* Ref.set(sessionRef, session); + yield* emitSessionEvent("session/ready", "Codex App Server session ready."); + return session; + }); + + const readProviderThreadId = Effect.gen(function* () { + const providerThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if (!providerThreadId) { + return yield* new CodexSessionRuntimeThreadIdMissingError({ + threadId: options.threadId, + }); + } + return providerThreadId; + }); + + const close = Effect.gen(function* () { + const alreadyClosed = yield* Ref.getAndSet(closedRef, true); + if (alreadyClosed) { + return; + } + yield* settlePendingApprovals("cancel"); + yield* settlePendingUserInputs({}); + yield* updateSession(sessionRef, { + status: "closed", + activeTurnId: undefined, + }); + yield* emitSessionEvent("session/closed", "Session stopped"); + yield* Scope.close(runtimeScope, Exit.void); + yield* Queue.shutdown(serverNotifications); + yield* Queue.shutdown(events); + }); + + return { + start, + getSession: Ref.get(sessionRef), + sendTurn: (input) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + const normalizedModel = normalizeCodexModelSlug( + input.model ?? (yield* Ref.get(sessionRef)).model, + ); + const params = yield* buildTurnStartParams({ + threadId: providerThreadId, + runtimeMode: options.runtimeMode, + ...(input.input ? { prompt: input.input } : {}), + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(normalizedModel ? { model: normalizedModel } : {}), + ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.effort ? { effort: input.effort } : {}), + ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + }); + const rawResponse = yield* client.raw.request("turn/start", params); + const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( + Effect.mapError((error) => + toProtocolParseError("Invalid turn/start response payload", error), + ), + ); + const turnId = TurnId.make(response.turn.id); + yield* updateSession(sessionRef, { + status: "running", + activeTurnId: turnId, + ...(normalizedModel ? { model: normalizedModel } : {}), + }); + const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + return { + threadId: options.threadId, + turnId, + ...(resumedProviderThreadId + ? { resumeCursor: { threadId: resumedProviderThreadId } } + : {}), + } satisfies ProviderTurnStartResult; + }), + interruptTurn: (turnId) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + const session = yield* Ref.get(sessionRef); + const effectiveTurnId = turnId ?? session.activeTurnId; + if (!effectiveTurnId) { + return; + } + yield* client.request("turn/interrupt", { + threadId: providerThreadId, + turnId: effectiveTurnId, + }); + }), + readThread: Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + const response = yield* client.request("thread/read", { + threadId: providerThreadId, + includeTurns: true, + }); + return parseThreadSnapshot(response); + }), + rollbackThread: (numTurns) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + const response = yield* client.request("thread/rollback", { + threadId: providerThreadId, + numTurns, + }); + yield* updateSession(sessionRef, { + status: "ready", + activeTurnId: undefined, + }); + return parseThreadSnapshot(response); + }), + respondToRequest: (requestId, decision) => + Effect.gen(function* () { + const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); + if (!pending) { + return yield* new CodexSessionRuntimePendingApprovalNotFoundError({ + requestId, + }); + } + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }); + yield* Deferred.succeed(pending.decision, decision); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "item/requestApproval/decision", + requestId: pending.requestId, + requestKind: pending.requestKind, + ...(pending.turnId ? { turnId: pending.turnId } : {}), + ...(pending.itemId ? { itemId: pending.itemId } : {}), + payload: { + requestId: pending.requestId, + requestKind: pending.requestKind, + decision, + }, + }); + }), + respondToUserInput: (requestId, answers) => + Effect.gen(function* () { + const pending = (yield* Ref.get(pendingUserInputsRef)).get(requestId); + if (!pending) { + return yield* new CodexSessionRuntimePendingUserInputNotFoundError({ + requestId, + }); + } + const codexAnswers = yield* toCodexUserInputAnswers(answers); + yield* Ref.update(pendingUserInputsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }); + yield* Deferred.succeed(pending.answers, answers); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "item/tool/requestUserInput/answered", + requestId: pending.requestId, + ...(pending.turnId ? { turnId: pending.turnId } : {}), + ...(pending.itemId ? { itemId: pending.itemId } : {}), + payload: { + answers: codexAnswers, + }, + }); + }), + events: Stream.fromQueue(events), + close, + } satisfies CodexSessionRuntimeShape; + }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts new file mode 100644 index 00000000000..43644fd6f49 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,1316 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as path from "node:path"; +import * as os from "node:os"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +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 Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { createModelSelection } from "@t3tools/shared/model"; + +import { + ApprovalRequestId, + CursorSettings, + ProviderDriverKind, + type ProviderRuntimeEvent, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapter } from "./CursorAdapter.ts"; +const decodeCursorSettings = Schema.decodeSync(CursorSettings); + +// Test-local service tag so the rest of the file can keep using `yield* CursorAdapter`. +class CursorAdapter extends Context.Service()( + "test/CursorAdapter", +) {} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const bunExe = "bun"; + +async function makeMockAgentWrapper( + extraEnv?: Record, + options?: { initialDelaySeconds?: number }, +) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} +exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function makeProbeWrapper( + requestLogPath: string, + argvLogPath: string, + extraEnv?: Record, +) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} +printf '\n' >> ${JSON.stringify(argvLogPath)} +export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} +${envExports} +exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readArgvLog(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.split("\t").filter((token) => token.length > 0)); +} + +async function readJsonLines(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +async function waitForFileContent(filePath: string, attempts = 40) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const raw = await readFile(filePath, "utf8"); + if (raw.trim().length > 0) { + return raw; + } + } catch {} + await Effect.runPromise(Effect.yieldNow); + } + throw new Error(`Timed out waiting for file content at ${filePath}`); +} + +// Tests mutate `ServerSettingsService` mid-flight (e.g. setting +// `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter +// captures `cursorSettings` once at construction, so without a resolver +// the mutation is invisible — sessions would spawn the constructor's +// (empty) binary path. Wiring `resolveSettings` through +// `ServerSettingsService.getSettings` makes each session read the latest +// snapshot, matching the old "always read live" behavior that these +// tests assumed. +const makeResolveCursorSettings = Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + return yield* Effect.succeed( + serverSettings.getSettings.pipe( + Effect.map((snapshot) => snapshot.providers.cursor), + Effect.orDie, + ), + ); +}); + +const cursorAdapterTestLayer = it.layer( + Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const cursorConfig = decodeCursorSettings({}); + const resolveSettings = yield* makeResolveCursorSettings; + return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), +); + +cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-mock-thread"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + assert.equal(session.provider, "cursor"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const types = runtimeEvents.map((e) => e.type); + + for (const t of [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "turn.plan.updated", + "item.started", + "content.delta", + "item.completed", + "turn.completed", + ] as const) { + assert.include(types, t); + } + + const assistantStarted = runtimeEvents.find( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + assert.isDefined(assistantStarted); + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/); + } + + const assistantCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + assert.isDefined(assistantCompleted); + + const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + assert.isDefined(planUpdate); + if (planUpdate?.type === "turn.plan.updated") { + assert.deepStrictEqual(planUpdate.payload.plan, [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ]); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes the ACP child process when a session stops", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-stop-session-close"); + const tempDir = yield* Effect.promise(() => + mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")), + ); + const exitLogPath = path.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + assert.include(exitLog, "SIGTERM"); + }), + ); + + it.effect( + "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", + () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-concurrent-start-session"); + const tempDir = yield* Effect.promise(() => + mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")), + ); + const exitLogPath = path.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }, + { initialDelaySeconds: 0.2 }, + ), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const [firstSession, secondSession] = yield* Effect.all( + [ + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }), + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(firstSession.threadId, threadId); + assert.equal(secondSession.threadId, threadId); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const result = yield* adapter + .startSession({ + threadId: ThreadId.make("bad-provider"), + provider: ProviderDriverKind.make("codex"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("maps app plan mode onto the ACP plan session mode", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-plan-mode-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "plan this change", + attachments: [], + interactionMode: "plan", + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modeRequest = requests + .toReversed() + .find( + (entry) => + entry.method === "session/set_mode" || + (entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "mode"), + ); + assert.isDefined(modeRequest); + assert.equal( + (modeRequest?.params as Record | undefined)?.sessionId, + "mock-session-1", + ); + assert.include( + ["architect", "plan"], + String( + (modeRequest?.params as Record | undefined)?.modeId ?? + (modeRequest?.params as Record | undefined)?.value, + ), + ); + }), + ); + + it.effect( + "applies initial model and mode configuration during startSession and skips repeating it on first send", + () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-initial-config-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + const modelSelection = createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ]); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection, + }); + + yield* Effect.promise(() => waitForFileContent(requestLogPath)); + + const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const configIdsAfterStart = requestsAfterStart.flatMap((entry) => + entry.method === "session/set_config_option" && + typeof (entry.params as Record | undefined)?.configId === "string" + ? [String((entry.params as Record).configId)] + : [], + ); + assert.deepStrictEqual(configIdsAfterStart, [ + "model", + "reasoning", + "context", + "fast", + "mode", + ]); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + modelSelection, + interactionMode: "default", + }); + yield* adapter.stopSession(threadId); + + const finalRequests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const finalConfigIds = finalRequests.flatMap((entry) => + entry.method === "session/set_config_option" && + typeof (entry.params as Record | undefined)?.configId === "string" + ? [String((entry.params as Record).configId)] + : [], + ); + assert.deepStrictEqual(finalConfigIds, ["model", "reasoning", "context", "fast", "mode"]); + assert.equal(finalRequests.filter((entry) => entry.method === "session/prompt").length, 1); + }), + ); + + it.effect( + "streams ACP tool calls and approvals on the active turn in approval-required mode", + () => + Effect.gen(function* () { + const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS; + process.env.T3_ACP_EMIT_TOOL_CALLS = "1"; + + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-tool-call-probe"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && event.requestId) { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "accept", + ); + } + if ( + event.type === "turn.completed" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + const program = Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a tool call", + attachments: [], + }); + yield* Deferred.await(settledEventsReady); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + assert.includeMembers( + threadEvents.map((event) => event.type), + [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "request.opened", + "request.resolved", + "item.updated", + "item.completed", + "content.delta", + "turn.completed", + ], + ); + + const turnEvents = threadEvents.filter( + (event) => String(event.turnId) === String(turn.turnId), + ); + const toolUpdates = turnEvents.filter((event) => event.type === "item.updated"); + // ACP updates can arrive either as distinct pending + in-progress events + // or as a single coalesced in-progress update before approval resolves. + assert.isAtLeast(toolUpdates.length, 1); + for (const toolUpdate of toolUpdates) { + if (toolUpdate.type !== "item.updated") { + continue; + } + assert.equal(toolUpdate.payload.itemType, "command_execution"); + assert.equal(toolUpdate.payload.status, "inProgress"); + assert.equal(toolUpdate.payload.detail, "cat server/package.json"); + assert.equal(String(toolUpdate.itemId), "tool-call-1"); + } + + const requestOpened = turnEvents.find((event) => event.type === "request.opened"); + assert.isDefined(requestOpened); + if (requestOpened?.type === "request.opened") { + assert.equal(String(requestOpened.turnId), String(turn.turnId)); + assert.equal(requestOpened.payload.requestType, "exec_command_approval"); + assert.equal(requestOpened.payload.detail, "cat server/package.json"); + } + + const requestResolved = turnEvents.find((event) => event.type === "request.resolved"); + assert.isDefined(requestResolved); + if (requestResolved?.type === "request.resolved") { + assert.equal(String(requestResolved.turnId), String(turn.turnId)); + assert.equal(requestResolved.payload.requestType, "exec_command_approval"); + assert.equal(requestResolved.payload.decision, "accept"); + } + + const toolCompleted = turnEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + assert.isDefined(toolCompleted); + if (toolCompleted?.type === "item.completed") { + assert.equal(String(toolCompleted.turnId), String(turn.turnId)); + assert.equal(toolCompleted.payload.itemType, "command_execution"); + assert.equal(toolCompleted.payload.status, "completed"); + assert.equal(toolCompleted.payload.detail, "cat server/package.json"); + assert.equal(String(toolCompleted.itemId), "tool-call-1"); + } + + const contentDelta = turnEvents.find((event) => event.type === "content.delta"); + assert.isDefined(contentDelta); + if (contentDelta?.type === "content.delta") { + assert.equal(String(contentDelta.turnId), String(turn.turnId)); + assert.equal(contentDelta.payload.delta, "hello from mock"); + assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0"); + } + }); + + yield* program.pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousEmitToolCalls === undefined) { + delete process.env.T3_ACP_EMIT_TOOL_CALLS; + } else { + process.env.T3_ACP_EMIT_TOOL_CALLS = previousEmitToolCalls; + } + }), + ), + ); + }).pipe( + Effect.provide( + Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const cursorConfig = decodeCursorSettings({}); + const resolveSettings = yield* makeResolveCursorSettings; + return yield* makeCursorAdapter(cursorConfig, { resolveSettings }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect( + "auto-approves ACP tool permissions in full-access mode without approval runtime events", + () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-full-access-auto-approve"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if ( + event.type === "turn.completed" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a tool call", + attachments: [], + }); + + yield* Deferred.await(settledEventsReady); + yield* Fiber.interrupt(runtimeEventsFiber); + + const turnEvents = runtimeEvents.filter( + (event) => + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turn.turnId), + ); + assert.notInclude( + turnEvents.map((event) => event.type), + "request.opened", + ); + assert.notInclude( + turnEvents.map((event) => event.type), + "request.resolved", + ); + assert.includeMembers( + turnEvents.map((event) => event.type), + ["item.updated", "item.completed", "content.delta", "turn.completed"], + ); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const permissionResponse = requests.find( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "outcome" in entry.result.outcome && + entry.result.outcome.outcome === "selected" && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "allow-always", + ); + assert.isDefined(permissionResponse); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("segments assistant messages around ACP tool activity in full-access mode", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-assistant-tool-segmentation"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if ( + event.type === "content.delta" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "turn.completed" + ) { + if (event.type === "content.delta") { + settledEventTypes.add(`delta:${event.payload.delta}`); + } else { + settledEventTypes.add(event.type); + } + if ( + settledEventTypes.has("delta:before tool") && + settledEventTypes.has("delta:after tool") && + settledEventTypes.has("item.completed") && + settledEventTypes.has("turn.completed") + ) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run an interleaved tool call", + attachments: [], + }); + + yield* Deferred.await(settledEventsReady); + yield* Fiber.interrupt(runtimeEventsFiber); + + const turnEvents = runtimeEvents.filter( + (event) => + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turn.turnId), + ); + const firstAssistantStartIndex = turnEvents.findIndex( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + const firstAssistantDeltaIndex = turnEvents.findIndex( + (event) => event.type === "content.delta" && event.payload.delta === "before tool", + ); + const assistantBoundaryIndex = turnEvents.findIndex( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + const toolUpdateIndex = turnEvents.findIndex( + (event) => event.type === "item.updated" && event.payload.itemType === "command_execution", + ); + const toolCompletedIndex = turnEvents.findIndex( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + const secondAssistantStartIndex = turnEvents.findIndex( + (event, index) => + index > toolCompletedIndex && + event.type === "item.started" && + event.payload.itemType === "assistant_message", + ); + const secondAssistantDeltaIndex = turnEvents.findIndex( + (event) => event.type === "content.delta" && event.payload.delta === "after tool", + ); + + assert.isAtLeast(firstAssistantStartIndex, 0); + assert.isAtLeast(firstAssistantDeltaIndex, 0); + assert.isAtLeast(assistantBoundaryIndex, 0); + assert.isAtLeast(toolUpdateIndex, 0); + assert.isAtLeast(toolCompletedIndex, 0); + assert.isAtLeast(secondAssistantStartIndex, 0); + assert.isAtLeast(secondAssistantDeltaIndex, 0); + assert.isBelow(firstAssistantStartIndex, firstAssistantDeltaIndex); + assert.isBelow(firstAssistantDeltaIndex, assistantBoundaryIndex); + assert.isBelow(assistantBoundaryIndex, toolUpdateIndex); + assert.isBelow(toolUpdateIndex, toolCompletedIndex); + assert.isBelow(toolCompletedIndex, secondAssistantStartIndex); + assert.isBelow(secondAssistantStartIndex, secondAssistantDeltaIndex); + + const assistantStarts = turnEvents.filter( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + const assistantDeltas = turnEvents.filter((event) => event.type === "content.delta"); + assert.lengthOf(assistantStarts, 2); + assert.lengthOf(assistantDeltas, 2); + if ( + assistantStarts[0]?.type === "item.started" && + assistantStarts[1]?.type === "item.started" && + assistantDeltas[0]?.type === "content.delta" && + assistantDeltas[1]?.type === "content.delta" + ) { + assert.notEqual(String(assistantStarts[0].itemId), String(assistantStarts[1].itemId)); + assert.equal(String(assistantDeltas[0].itemId), String(assistantStarts[0].itemId)); + assert.equal(String(assistantDeltas[1].itemId), String(assistantStarts[1].itemId)); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("cancels pending ACP approvals and marks the turn cancelled when interrupted", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-cancel-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const requestResolvedReady = yield* Deferred.make(); + const turnCompletedReady = yield* Deferred.make(); + let interrupted = false; + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && !interrupted) { + interrupted = true; + yield* adapter.interruptTurn(threadId); + return; + } + if (event.type === "request.resolved") { + yield* Deferred.succeed(requestResolvedReady, event).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompletedReady, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "cancel this turn", + attachments: [], + }) + .pipe(Effect.forkChild); + + const requestResolved = yield* Deferred.await(requestResolvedReady); + const turnCompleted = yield* Deferred.await(turnCompletedReady); + yield* Fiber.join(sendTurnFiber); + yield* Fiber.interrupt(runtimeEventsFiber); + + assert.equal(requestResolved.type, "request.resolved"); + if (requestResolved.type === "request.resolved") { + assert.equal(requestResolved.payload.decision, "cancel"); + } + + assert.equal(turnCompleted.type, "turn.completed"); + if (turnCompleted.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "cancelled"); + assert.equal(turnCompleted.payload.stopReason, "cancelled"); + } + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); + assert.isTrue( + requests.some( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "outcome" in entry.result.outcome && + entry.result.outcome.outcome === "cancelled", + ), + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("stopping a session settles pending approval waits", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-stop-pending-approval"); + const approvalRequested = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") { + return Effect.void; + } + return Deferred.succeed(approvalRequested, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "run a tool call and then stop", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(approvalRequested); + yield* adapter.stopSession(threadId); + yield* Fiber.await(sendTurnFiber); + + assert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("stopping a session settles pending user-input waits", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-stop-pending-user-input"); + const userInputRequested = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") { + return Effect.void; + } + return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "ask me a question and then stop", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(userInputRequested); + yield* adapter.stopSession(threadId); + yield* Fiber.await(sendTurnFiber); + + assert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("interrupting a session settles pending user-input waits", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-interrupt-pending-user-input"); + const userInputRequested = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") { + return Effect.void; + } + return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "ask me a question and then interrupt", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(userInputRequested); + yield* adapter.interruptTurn(threadId); + yield* Fiber.await(sendTurnFiber); + + assert.equal(yield* adapter.hasSession(threadId), true); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("broadcasts runtime events to multiple stream consumers", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-runtime-event-broadcast"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const firstConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + const secondConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + + const firstEvents = Array.from(yield* Fiber.join(firstConsumer)); + const secondEvents = Array.from(yield* Fiber.join(secondConsumer)); + + assert.deepStrictEqual( + firstEvents.map((event) => event.type), + ["session.started", "session.state.changed", "thread.started"], + ); + assert.deepStrictEqual( + secondEvents.map((event) => event.type), + ["session.started", "session.state.changed", "thread.started"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("switches model in-session via session/set_config_option", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-model-switch"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn after switching model", + attachments: [], + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: true }, + ]), + }); + + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); + assert.deepStrictEqual(argvRuns[0], ["acp"]); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const setConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "model", + ); + assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); + assert.equal((setConfigRequests[0]?.params as Record)?.value, "composer-2"); + + const fastConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "fast", + ); + assert.isAbove(fastConfigRequests.length, 0, "should apply fast mode as a separate config"); + const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; + assert.equal((lastFastConfig?.params as Record)?.value, "true"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("clears prior fast mode in-session when the next turn sets fastMode: false", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-fast-mode-reset"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn with fast mode", + attachments: [], + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: true }, + ]), + }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn without fast mode", + attachments: [], + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: false }, + ]), + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const fastConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "fast", + ); + assert.isAtLeast(fastConfigRequests.length, 2, "should set fast mode on and then off"); + + const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; + assert.equal((lastFastConfig?.params as Record)?.value, "false"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "applies fast mode on the first turn when modelSelection uses a non-default instance id", + () => { + const customInstanceId = ProviderInstanceId.make("cursor_secondary"); + // Custom-instance cases can't share the suite-level `CursorAdapter` + // layer because that one binds `instanceId: "cursor"`. We build a + // fresh layer graph — including a fresh `ServerSettingsService` — so + // mid-test `updateSettings` calls target the same service instance the + // adapter's `resolveSettings` reads from, and so the outer + // `yield* ServerSettingsService` sees the same snapshot as well. + const customAdapterLayer = Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const cursorConfig = decodeCursorSettings({}); + const resolveSettings = yield* makeResolveCursorSettings; + return yield* makeCursorAdapter(cursorConfig, { + instanceId: customInstanceId, + resolveSettings, + }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-custom-instance-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-fast-mode-custom-instance"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: customInstanceId, + model: "composer-2", + }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn with fast mode", + attachments: [], + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("cursor"), "composer-2", [ + { id: "fastMode", value: true }, + ]), + instanceId: customInstanceId, + }, + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const fastConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "fast", + ); + assert.isAbove( + fastConfigRequests.length, + 0, + "fast mode should apply when instance id matches the adapter binding", + ); + const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; + assert.equal((lastFastConfig?.params as Record)?.value, "true"); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(customAdapterLayer)); + }, + ); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 00000000000..efef5f0a83b --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,1083 @@ +/** + * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP. + * + * @module CursorAdapterLive + */ + +import { + ApprovalRequestId, + type CursorSettings, + type ProviderOptionSelection, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Random from "effect/Random"; +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 SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; +import { + CursorAskQuestionRequest, + CursorCreatePlanRequest, + CursorUpdateTodosRequest, + extractAskQuestions, + extractPlanMarkdown, + extractTodosAsPlan, +} from "../acp/CursorAcpExtension.ts"; +import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +const PROVIDER = ProviderDriverKind.make("cursor"); +const CURSOR_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface CursorAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`cursor`). + */ + readonly instanceId?: typeof ProviderInstanceId.Type; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `cursorSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface CursorSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCursorResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntimeShape; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyCursorAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +export function makeCursorAdapter( + cursorSettings: CursorSettings, + options?: CursorAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.cursor.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* Random.nextUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: CursorSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.cursor.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: CursorSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: CursorAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const cursorModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: CursorSessionContext; + + const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the CursorSettings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { cursor: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveCursorSettings = options?.resolveSettings + ? yield* options.resolveSettings + : cursorSettings; + + const acp = yield* makeCursorAcpRuntime({ + cursorSettings: effectiveCursorSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions: extractAskQuestions(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + }); + return { answers: resolved }; + }), + ); + yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/create_plan", + params, + "acp.cursor.extension", + ); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + payload: { planMarkdown: extractPlanMarkdown(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, + }); + return { accepted: true } as const; + }), + ); + yield* acp.handleExtNotification( + "cursor/update_todos", + CursorUpdateTodosRequest, + (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/update_todos", + params, + "acp.cursor.extension", + ); + if (ctx) { + yield* emitPlanUpdate( + ctx, + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + } + }), + ); + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: cursorModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: cursorModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: CURSOR_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Cursor ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveCursorAcpBaseModelId(model); + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "cursor/ask_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: CursorAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies CursorAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts new file mode 100644 index 00000000000..90b36e89004 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -0,0 +1,783 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { describe, expect, it } from "vitest"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildCursorProviderSnapshot, + buildCursorCapabilitiesFromConfigOptions, + buildCursorDiscoveredModelsFromConfigOptions, + checkCursorProviderStatus, + discoverCursorModelCapabilitiesViaAcp, + discoverCursorModelsViaAcp, + getCursorFallbackModels, + getCursorParameterizedModelPickerUnsupportedMessage, + parseCursorAboutOutput, + parseCursorCliConfigChannel, + parseCursorVersionDate, + resolveCursorAcpBaseModelId, + resolveCursorAcpConfigUpdates, +} from "./CursorProvider.ts"; + +const runNode = ( + effect: Effect.Effect, +): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); + +const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)); +}); + +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string, currentValue?: boolean) { + return { + id, + label, + type: "boolean" as const, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; +} + +const makeMockAgentWrapper = Effect.fn("makeMockAgentWrapper")(function* ( + extraEnv?: Record, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-mock-", + }); + const wrapperPath = path.join(dir, "fake-agent.sh"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const bunCommand = JSON.stringify("bun"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const mockAgentPathJson = JSON.stringify(mockAgentPath); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${bunCommand} ${mockAgentPathJson} "$@" +`; + yield* fileSystem.writeFileString(wrapperPath, script); + yield* fileSystem.chmod(wrapperPath, 0o755); + return wrapperPath; +}); + +const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* resolveMockAgentPath(); + const dir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-about-mock-", + }); + const wrapperPath = path.join(dir, "fake-agent.sh"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const bunCommand = JSON.stringify("bun"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const mockAgentPathJson = JSON.stringify(mockAgentPath); + const script = `#!/bin/sh +if [ "$1" = "about" ]; then + printf 'CLI Version 2026.04.09-f2b0fcd\\n' + printf 'User Email cursor@example.com\\n' + exit 0 +fi +exec ${bunCommand} ${mockAgentPathJson} "$@" +`; + yield* fileSystem.writeFileString(wrapperPath, script); + yield* fileSystem.chmod(wrapperPath, 0o755); + return wrapperPath; +}); + +const waitForFileContent = Effect.fn("waitForFileContent")(function* ( + filePath: string, + attempts = 40, +) { + const fileSystem = yield* FileSystem.FileSystem; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const content = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.catch(() => Effect.void)); + if (content !== undefined) { + if (content.trim().length > 0) { + return content; + } + } + yield* Effect.sleep("50 millis"); + } + return yield* Effect.die(`Timed out waiting for file content at ${filePath}`); +}); + +const makeProviderStatusEnvFixture = Effect.fn("makeProviderStatusEnvFixture")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-status-env-", + }); + return { + requestLogPath: path.join(tempDir, "requests.ndjson"), + wrapperPath: yield* makeMockAgentWithAboutWrapper(), + }; +}); + +const makeExitLogFixture = Effect.fn("makeExitLogFixture")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix, + }); + const exitLogPath = path.join(tempDir, "exit.log"); + return { + exitLogPath, + wrapperPath: yield* makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + }; +}); + +const parameterizedGpt54ConfigOptions = [ + { + type: "select", + currentValue: "gpt-5.4-medium-fast", + options: [{ name: "GPT-5.4", value: "gpt-5.4-medium-fast" }], + category: "model", + id: "model", + name: "Model", + }, + { + type: "select", + currentValue: "medium", + options: [ + { name: "None", value: "none" }, + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Extra High", value: "extra-high" }, + ], + category: "thought_level", + id: "reasoning", + name: "Reasoning", + }, + { + type: "select", + currentValue: "272k", + options: [ + { name: "272K", value: "272k" }, + { name: "1M", value: "1m" }, + ], + category: "model_config", + id: "context", + name: "Context", + }, + { + type: "select", + currentValue: "false", + options: [ + { name: "Off", value: "false" }, + { name: "Fast", value: "true" }, + ], + category: "model_config", + id: "fast", + name: "Fast", + }, +] satisfies ReadonlyArray; + +const parameterizedClaudeConfigOptions = [ + { + type: "select", + currentValue: "claude-4.6-opus-high-thinking", + options: [{ name: "Opus 4.6", value: "claude-4.6-opus-high-thinking" }], + category: "model", + id: "model", + name: "Model", + }, + { + type: "select", + currentValue: "high", + options: [ + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + ], + category: "thought_level", + id: "reasoning", + name: "Reasoning", + }, + { + type: "boolean", + currentValue: true, + category: "model_config", + id: "thinking", + name: "Thinking", + }, +] satisfies ReadonlyArray; + +const parameterizedClaudeModelOptionConfigOptions = [ + { + type: "select", + currentValue: "claude-opus-4-6", + options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }], + category: "model", + id: "model", + name: "Model", + }, + { + type: "select", + currentValue: "high", + options: [ + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + ], + category: "thought_level", + id: "reasoning", + name: "Reasoning", + }, + { + type: "select", + currentValue: "max", + options: [ + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Max", value: "max" }, + ], + category: "model_option", + id: "effort", + name: "Effort", + }, + { + type: "select", + currentValue: "true", + options: [ + { name: "Off", value: "false" }, + { name: "Fast", value: "true" }, + ], + category: "model_config", + id: "fast", + name: "Fast", + }, + { + type: "select", + currentValue: "true", + options: [ + { name: "Off", value: "false" }, + { name: ":icon-brain:", value: "true" }, + ], + category: "model_config", + id: "thinking", + name: "Thinking", + }, +] satisfies ReadonlyArray; + +const sessionNewCursorConfigOptions = [ + { + type: "select", + currentValue: "agent", + options: [ + { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" }, + ], + category: "mode", + id: "mode", + name: "Mode", + description: "Controls how the agent executes tasks", + }, + { + type: "select", + currentValue: "composer-2", + options: [ + { name: "Auto", value: "default" }, + { name: "Composer 2", value: "composer-2" }, + { name: "GPT-5.4", value: "gpt-5.4" }, + { name: "Sonnet 4.6", value: "claude-sonnet-4-6" }, + { name: "Opus 4.6", value: "claude-opus-4-6" }, + { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" }, + ], + category: "model", + id: "model", + name: "Model", + description: "Controls which model is used for responses", + }, + { + type: "select", + currentValue: "true", + options: [ + { name: "Off", value: "false" }, + { name: "Fast", value: "true" }, + ], + category: "model_config", + id: "fast", + name: "Fast", + description: "Faster speeds.", + }, +] satisfies ReadonlyArray; + +const baseCursorSettings: CursorSettings = { + enabled: true, + binaryPath: "agent", + apiEndpoint: "", + customModels: [], +}; + +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); + +describe("getCursorFallbackModels", () => { + it("does not publish any built-in cursor models before ACP discovery", () => { + expect( + getCursorFallbackModels({ + customModels: ["internal/cursor-model"], + }).map((model) => model.slug), + ).toEqual(["internal/cursor-model"]); + }); +}); + +describe("buildCursorProviderSnapshot", () => { + it("downgrades ready status to warning when ACP model discovery times out", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { + version: "2026.04.09-f2b0fcd", + status: "ready", + auth: { status: "authenticated", type: "Team", label: "Cursor Team Subscription" }, + }, + discoveryWarning: "Cursor ACP model discovery timed out after 15000ms.", + }), + ).toMatchObject({ + status: "warning", + message: "Cursor ACP model discovery timed out after 15000ms.", + models: [], + }); + }); + + it("preserves provider error state while appending discovery warnings", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: { + ...baseCursorSettings, + customModels: ["claude-sonnet-4-6"], + }, + parsed: { + version: "2026.04.09-f2b0fcd", + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }, + discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.", + }), + ).toMatchObject({ + status: "error", + message: + "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.", + models: [ + { + slug: "claude-sonnet-4-6", + isCustom: true, + }, + ], + }); + }); +}); + +describe("buildCursorCapabilitiesFromConfigOptions", () => { + it("derives model capabilities from parameterized Cursor ACP config options", () => { + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + ]), + selectDescriptor("contextWindow", "Context", [ + { id: "272k", label: "272K", isDefault: true }, + { id: "1m", label: "1M" }, + ]), + booleanDescriptor("fastMode", "Fast", false), + ], + }), + ); + }); + + it("detects boolean thinking toggles from model_config options", () => { + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); + }); + + it("prefers the newer model_option effort control over legacy thought_level", () => { + expect( + buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions), + ).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Effort", [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast", true), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); + }); +}); + +describe("buildCursorDiscoveredModelsFromConfigOptions", () => { + it("publishes ACP model choices immediately from session/new config options", () => { + expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([ + { + slug: "default", + name: "Auto", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast", true)], + }), + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-sonnet-4-6", + name: "Sonnet 4.6", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "gpt-5.3-codex-spark", + name: "Codex 5.3 Spark", + isCustom: false, + capabilities: emptyCapabilities, + }, + ]); + }); +}); + +describe("checkCursorProviderStatus", () => { + it("passes the injected environment to ACP model discovery", async () => { + const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture()); + + const provider = await Effect.runPromise( + checkCursorProviderStatus( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + { + ...process.env, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + ).pipe(Effect.provide(NodeServices.layer)), + ); + + expect(provider.models.map((model) => model.slug)).toEqual([ + "default", + "composer-2", + "gpt-5.4", + "claude-opus-4-6", + ]); + await expect(runNode(waitForFileContent(requestLogPath))).resolves.toContain("initialize"); + }); +}); + +describe("discoverCursorModelsViaAcp", () => { + it("keeps the ACP probe runtime alive long enough to discover models", async () => { + const wrapperPath = await runNode(makeMockAgentWrapper()); + + const models = await Effect.runPromise( + discoverCursorModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + expect(models.map((model) => model.slug)).toEqual([ + "default", + "composer-2", + "gpt-5.4", + "claude-opus-4-6", + ]); + }); + + it("closes the ACP probe runtime after discovery completes", async () => { + const { exitLogPath, wrapperPath } = await runNode( + makeExitLogFixture("cursor-provider-exit-log-"), + ); + + await Effect.runPromise( + discoverCursorModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }).pipe(Effect.provide(NodeServices.layer)), + ); + + const exitLog = await runNode(waitForFileContent(exitLogPath)); + expect(exitLog).toContain("SIGTERM"); + }); +}); + +describe("discoverCursorModelCapabilitiesViaAcp", () => { + it("closes all ACP probe runtimes after capability enrichment completes", async () => { + const { exitLogPath, wrapperPath } = await runNode( + makeExitLogFixture("cursor-capabilities-exit-log-"), + ); + const existingModels: ReadonlyArray = [ + { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities }, + { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities }, + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: emptyCapabilities, + }, + ]; + + const models = await Effect.runPromise( + discoverCursorModelCapabilitiesViaAcp( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + existingModels, + ).pipe(Effect.provide(NodeServices.layer)), + ); + + expect(models.map((model) => model.slug)).toEqual([ + "default", + "composer-2", + "gpt-5.4", + "claude-opus-4-6", + ]); + + const exitLog = await runNode(waitForFileContent(exitLogPath)); + expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4); + }); +}); + +describe("parseCursorAboutOutput", () => { + it("parses json about output and forwards subscription metadata", () => { + expect( + parseCursorAboutOutput({ + code: 0, + stdout: JSON.stringify({ + cliVersion: "2026.04.09-f2b0fcd", + subscriptionTier: "Team", + userEmail: "jmarminge@gmail.com", + }), + stderr: "", + }), + ).toEqual({ + version: "2026.04.09-f2b0fcd", + status: "ready", + auth: { + status: "authenticated", + email: "jmarminge@gmail.com", + type: "Team", + label: "Cursor Team Subscription", + }, + }); + }); + + it("treats json about output with a logged-out email as unauthenticated", () => { + expect( + parseCursorAboutOutput({ + code: 0, + stdout: JSON.stringify({ + cliVersion: "2026.04.09-f2b0fcd", + subscriptionTier: "Team", + userEmail: "Not logged in", + }), + stderr: "", + }), + ).toEqual({ + version: "2026.04.09-f2b0fcd", + status: "error", + auth: { + status: "unauthenticated", + }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }); + }); + + it("treats json about output with a null email as unauthenticated", () => { + expect( + parseCursorAboutOutput({ + code: 0, + stdout: JSON.stringify({ + cliVersion: "2026.04.09-f2b0fcd", + subscriptionTier: null, + userEmail: null, + }), + stderr: "", + }), + ).toEqual({ + version: "2026.04.09-f2b0fcd", + status: "error", + auth: { + status: "unauthenticated", + }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }); + }); +}); + +describe("Cursor parameterized model picker preview gating", () => { + it("parses Cursor CLI version dates from build versions", () => { + expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408); + expect(parseCursorVersionDate("2026.04.09")).toBe(20260409); + expect(parseCursorVersionDate("not-a-version")).toBeUndefined(); + }); + + it("parses the Cursor CLI channel from cli-config.json", () => { + expect(parseCursorCliConfigChannel('{ "channel": "lab" }')).toBe("lab"); + expect(parseCursorCliConfigChannel('{ "channel": "stable" }')).toBe("stable"); + expect(parseCursorCliConfigChannel('{ "version": 1 }')).toBeUndefined(); + expect(parseCursorCliConfigChannel("not-json")).toBeUndefined(); + }); + + it("returns no warning when the preview requirements are met", () => { + expect( + getCursorParameterizedModelPickerUnsupportedMessage({ + version: "2026.04.08-c4e73a3", + channel: "lab", + }), + ).toBeUndefined(); + }); + + it("explains when the Cursor Agent version is too old", () => { + expect( + getCursorParameterizedModelPickerUnsupportedMessage({ + version: "2026.04.07-c4e73a3", + channel: "lab", + }), + ).toContain("too old"); + }); + + it("explains when the Cursor Agent channel is not lab", () => { + expect( + getCursorParameterizedModelPickerUnsupportedMessage({ + version: "2026.04.08-c4e73a3", + channel: "stable", + }), + ).toContain("lab channel"); + }); +}); + +describe("resolveCursorAcpBaseModelId", () => { + it("drops bracket traits without rewriting raw ACP model ids", () => { + expect(resolveCursorAcpBaseModelId("gpt-5.4[reasoning=medium,context=272k]")).toBe("gpt-5.4"); + expect(resolveCursorAcpBaseModelId("gpt-5.4-medium-fast")).toBe("gpt-5.4-medium-fast"); + expect(resolveCursorAcpBaseModelId("claude-4.6-opus-high-thinking")).toBe( + "claude-4.6-opus-high-thinking", + ); + expect(resolveCursorAcpBaseModelId("composer-2")).toBe("composer-2"); + expect(resolveCursorAcpBaseModelId("auto")).toBe("auto"); + }); +}); + +describe("resolveCursorAcpConfigUpdates", () => { + it("maps Cursor model options onto separate ACP config option updates", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), + ).toEqual([ + { configId: "reasoning", value: "extra-high" }, + { configId: "context", value: "1m" }, + { configId: "fast", value: "true" }, + ]); + }); + + it("maps boolean thinking toggles when the model exposes them separately", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, [ + { id: "thinking", value: false }, + ]), + ).toEqual([{ configId: "thinking", value: false }]); + }); + + it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, [ + { id: "fastMode", value: false }, + ]), + ).toEqual([{ configId: "fast", value: "false" }]); + }); + + it("writes Cursor effort changes through the newer model_option config when available", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, [ + { id: "reasoning", value: "max" }, + { id: "thinking", value: false }, + ]), + ).toEqual([ + { configId: "effort", value: "max" }, + { configId: "thinking", value: "false" }, + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts new file mode 100644 index 00000000000..035c08437a9 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -0,0 +1,1296 @@ +import * as NodeOs from "node:os"; +import type { + CursorSettings, + ModelCapabilities, + ProviderOptionSelection, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + createModelCapabilities, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, +} from "@t3tools/shared/model"; + +import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; + +const PROVIDER = ProviderDriverKind.make("cursor"); +const CURSOR_PRESENTATION = { + displayName: "Cursor", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; +const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; +const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; +export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { + _meta: { + parameterizedModelPicker: true, + }, +} satisfies NonNullable; + +export function buildInitialCursorProviderSnapshot( + cursorSettings: CursorSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getCursorFallbackModels(cursorSettings); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Cursor Agent availability...", + }, + }); + }); +} + +interface CursorSessionSelectOption { + readonly value: string; + readonly name: string; +} + +interface CursorAcpDiscoveredModel { + readonly slug: string; + readonly name: string; + readonly capabilities: ModelCapabilities; +} + +function flattenSessionConfigSelectOptions( + configOption: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => + "value" in entry + ? [ + { + value: entry.value.trim(), + name: entry.name.trim(), + } satisfies CursorSessionSelectOption, + ] + : entry.options.map( + (option) => + ({ + value: option.value.trim(), + name: option.name.trim(), + }) satisfies CursorSessionSelectOption, + ), + ); +} + +function normalizeCursorReasoningValue(value: string | null | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + switch (normalized) { + case "low": + case "medium": + case "high": + case "max": + return normalized; + case "xhigh": + case "extra-high": + case "extra high": + return "xhigh"; + default: + return undefined; + } +} + +function findCursorModelConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + return configOptions.find((option) => option.category === "model"); +} + +function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string { + return option.category?.trim().toLowerCase() ?? ""; +} + +function isCursorEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return ( + id === "effort" || + id === "reasoning" || + name === "effort" || + name === "reasoning" || + name.includes("effort") || + name.includes("reasoning") + ); +} + +function findCursorEffortConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + const candidates = configOptions.filter( + (option) => option.type === "select" && isCursorEffortConfigOption(option), + ); + return ( + candidates.find((option) => getCursorConfigOptionCategory(option) === "model_option") ?? + candidates.find((option) => option.id.trim().toLowerCase() === "effort") ?? + candidates.find((option) => getCursorConfigOptionCategory(option) === "thought_level") ?? + candidates[0] + ); +} + +function isCursorContextConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "context" || id === "context_size" || name.includes("context"); +} + +function isCursorFastConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "fast" || name === "fast" || name.includes("fast mode"); +} + +function isCursorThinkingConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "thinking" || name.includes("thinking"); +} + +function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + if (option.type === "boolean") { + return true; + } + if (option.type !== "select") { + return false; + } + const values = new Set( + flattenSessionConfigSelectOptions(option).map((entry) => entry.value.trim().toLowerCase()), + ); + return values.has("true") && values.has("false"); +} + +function getBooleanCurrentValue( + option: EffectAcpSchema.SessionConfigOption | undefined, +): boolean | undefined { + if (!option) { + return undefined; + } + if (option.type === "boolean") { + return option.currentValue; + } + if (option.type !== "select") { + return undefined; + } + const normalized = option.currentValue?.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + return undefined; +} + +export function buildCursorCapabilitiesFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + if (!configOptions || configOptions.length === 0) { + return EMPTY_CAPABILITIES; + } + + const reasoningConfig = findCursorEffortConfigOption(configOptions); + const reasoningEffortLevels = + reasoningConfig?.type === "select" + ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => { + const normalizedValue = normalizeCursorReasoningValue(entry.value); + if (!normalizedValue) { + return []; + } + return [ + { + value: normalizedValue, + label: entry.name, + ...(normalizeCursorReasoningValue(reasoningConfig.currentValue) === normalizedValue + ? { isDefault: true } + : {}), + }, + ]; + }) + : []; + + const contextOption = configOptions.find( + (option) => option.category === "model_config" && isCursorContextConfigOption(option), + ); + const contextWindowOptions = + contextOption?.type === "select" + ? flattenSessionConfigSelectOptions(contextOption).map((entry) => { + if (contextOption.currentValue === entry.value) { + return { + value: entry.value, + label: entry.name, + isDefault: true, + }; + } + return { + value: entry.value, + label: entry.name, + }; + }) + : []; + + const fastOption = configOptions.find( + (option) => option.category === "model_config" && isCursorFastConfigOption(option), + ); + const thinkingOption = configOptions.find( + (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), + ); + const fastCurrentValue = getBooleanCurrentValue(fastOption); + const thinkingCurrentValue = getBooleanCurrentValue(thinkingOption); + const optionDescriptors = [ + ...(reasoningEffortLevels.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "reasoning", + label: reasoningConfig?.name?.trim() || "Reasoning", + options: reasoningEffortLevels, + }), + ] + : []), + ...(contextWindowOptions.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "contextWindow", + label: contextOption?.name?.trim() || "Context Window", + options: contextWindowOptions, + }), + ] + : []), + ...(fastOption && isBooleanLikeConfigOption(fastOption) + ? [ + typeof fastCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + currentValue: fastCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "fastMode", + label: fastOption.name?.trim() || "Fast Mode", + }), + ] + : []), + ...(thinkingOption && isBooleanLikeConfigOption(thinkingOption) + ? [ + typeof thinkingCurrentValue === "boolean" + ? buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + currentValue: thinkingCurrentValue, + }) + : buildBooleanOptionDescriptor({ + id: "thinking", + label: thinkingOption.name?.trim() || "Thinking", + }), + ] + : []), + ]; + + return createModelCapabilities({ + optionDescriptors, + }); +} + +function buildCursorDiscoveredModels( + discoveredModels: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return discoveredModels.flatMap((model) => { + if (!model.slug || seen.has(model.slug)) { + return []; + } + seen.add(model.slug); + return [ + { + slug: model.slug, + name: model.name, + isCustom: false, + capabilities: model.capabilities, + } satisfies ServerProviderModel, + ]; + }); +} + +function hasCursorModelCapabilities(model: Pick): boolean { + return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +} + +export function buildCursorDiscoveredModelsFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + if (!configOptions || configOptions.length === 0) { + return []; + } + + const modelOption = findCursorModelConfigOption(configOptions); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + if (!modelOption || modelChoices.length === 0) { + return []; + } + + const currentModelValue = + modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + const currentModelCapabilities = buildCursorCapabilitiesFromConfigOptions(configOptions); + + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: + currentModelValue === modelChoice.value.trim() + ? currentModelCapabilities + : EMPTY_CAPABILITIES, + })), + ); +} + +const makeCursorAcpProbeRuntime = ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: cursorSettings.binaryPath, + args: [ + ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + "acp", + ], + cwd: process.cwd(), + env: environment, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + authMethodId: "cursor_login", + clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }); + +const withCursorAcpProbeRuntime = ( + cursorSettings: CursorSettings, + useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, + environment: NodeJS.ProcessEnv = process.env, +) => + makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( + Effect.flatMap(useRuntime), + Effect.scoped, + ); + +function normalizeCursorConfigOptionToken(value: string | null | undefined): string { + return ( + value + ?.trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-") ?? "" + ); +} + +function findCursorSelectOptionValue( + configOption: EffectAcpSchema.SessionConfigOption | undefined, + matcher: (option: CursorSessionSelectOption) => boolean, +): string | undefined { + return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value; +} + +function findCursorBooleanConfigValue( + configOption: EffectAcpSchema.SessionConfigOption | undefined, + requested: boolean, +): string | boolean | undefined { + if (!configOption) { + return undefined; + } + if (configOption.type === "boolean") { + return requested; + } + return findCursorSelectOptionValue( + configOption, + (option) => normalizeCursorConfigOptionToken(option.value) === String(requested), + ); +} + +export function resolveCursorAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "default"; + return base.includes("[") ? base.slice(0, base.indexOf("[")) : base; +} + +export function resolveCursorAcpConfigUpdates( + configOptions: ReadonlyArray | null | undefined, + selections: ReadonlyArray | null | undefined, +): ReadonlyArray<{ + readonly configId: string; + readonly value: string | boolean; +}> { + if (!configOptions || configOptions.length === 0) { + return []; + } + + const updates: Array<{ + readonly configId: string; + readonly value: string | boolean; + }> = []; + + const reasoningOption = findCursorEffortConfigOption(configOptions); + const requestedReasoning = normalizeCursorReasoningValue( + getProviderOptionStringSelectionValue(selections, "reasoning"), + ); + if (reasoningOption && requestedReasoning) { + const value = findCursorSelectOptionValue(reasoningOption, (option) => { + const normalizedValue = normalizeCursorReasoningValue(option.value); + const normalizedName = normalizeCursorReasoningValue(option.name); + return normalizedValue === requestedReasoning || normalizedName === requestedReasoning; + }); + if (value) { + updates.push({ configId: reasoningOption.id, value }); + } + } + + const contextOption = configOptions.find( + (option) => option.category === "model_config" && isCursorContextConfigOption(option), + ); + const requestedContextWindow = getProviderOptionStringSelectionValue(selections, "contextWindow"); + if (contextOption && requestedContextWindow) { + const value = findCursorSelectOptionValue( + contextOption, + (option) => + normalizeCursorConfigOptionToken(option.value) === + normalizeCursorConfigOptionToken(requestedContextWindow) || + normalizeCursorConfigOptionToken(option.name) === + normalizeCursorConfigOptionToken(requestedContextWindow), + ); + if (value) { + updates.push({ configId: contextOption.id, value }); + } + } + + const fastOption = configOptions.find( + (option) => option.category === "model_config" && isCursorFastConfigOption(option), + ); + const requestedFastMode = getProviderOptionBooleanSelectionValue(selections, "fastMode"); + if (fastOption && typeof requestedFastMode === "boolean") { + const value = findCursorBooleanConfigValue(fastOption, requestedFastMode); + if (value !== undefined) { + updates.push({ configId: fastOption.id, value }); + } + } + + const thinkingOption = configOptions.find( + (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), + ); + const requestedThinking = getProviderOptionBooleanSelectionValue(selections, "thinking"); + if (thinkingOption && typeof requestedThinking === "boolean") { + const value = findCursorBooleanConfigValue(thinkingOption, requestedThinking); + if (value !== undefined) { + updates.push({ configId: thinkingOption.id, value }); + } + } + + return updates; +} + +export const discoverCursorModelsViaAcp = ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + withCursorAcpProbeRuntime( + cursorSettings, + (acp) => + Effect.map(acp.start(), (started) => + buildCursorDiscoveredModelsFromConfigOptions( + started.sessionSetupResult.configOptions ?? [], + ), + ), + environment, + ); + +export const discoverCursorModelCapabilitiesViaAcp = ( + cursorSettings: CursorSettings, + existingModels: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + withCursorAcpProbeRuntime( + cursorSettings, + (acp) => + Effect.gen(function* () { + const started = yield* acp.start(); + const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; + const modelOption = findCursorModelConfigOption(initialConfigOptions); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + if (!modelOption || modelChoices.length === 0) { + return []; + } + + const currentModelValue = + modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + const capabilitiesBySlug = new Map(); + if (currentModelValue) { + capabilitiesBySlug.set( + currentModelValue, + buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), + ); + } + + const targetModelSlugs = new Set( + existingModels + .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) + .map((model) => model.slug), + ); + if (targetModelSlugs.size === 0) { + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, + })), + ); + } + + const probedCapabilities = yield* Effect.forEach( + modelChoices, + (modelChoice) => { + const modelSlug = modelChoice.value.trim(); + if ( + !modelSlug || + !targetModelSlugs.has(modelSlug) || + capabilitiesBySlug.has(modelSlug) + ) { + return Effect.void.pipe( + Effect.as(undefined), + ); + } + + return withCursorAcpProbeRuntime( + cursorSettings, + (probeAcp) => + Effect.gen(function* () { + const probeStarted = yield* probeAcp.start(); + const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; + const probeModelOption = findCursorModelConfigOption(probeConfigOptions); + const probeCurrentModelValue = + probeModelOption?.type === "select" + ? probeModelOption.currentValue?.trim() || undefined + : undefined; + yield* Effect.annotateCurrentSpan({ + "cursor.acp.model.value": modelSlug, + "cursor.acp.model.currentValue": probeCurrentModelValue, + "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, + }); + const nextConfigOptions = + probeCurrentModelValue === modelSlug + ? probeConfigOptions + : yield* probeAcp + .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) + .pipe( + Effect.map((response) => response.configOptions ?? probeConfigOptions), + ); + return [ + modelSlug, + buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), + ] as const; + }), + environment, + ).pipe( + Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), + Effect.retry({ times: 3 }), + Effect.withSpan("cursor-acp-model-capability-probe"), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP capability probe failed", { + modelSlug, + cause: Cause.pretty(cause), + }), + ), + ); + }, + { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, + ); + + for (const entry of probedCapabilities) { + if (!entry) { + continue; + } + capabilitiesBySlug.set(entry[0], entry[1]); + } + + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, + })), + ); + }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), + environment, + ); + +export function getCursorFallbackModels( + cursorSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES); +} + +/** Timeout for `agent about` — it's slower than a simple `--version` probe. */ +const ABOUT_TIMEOUT_MS = 8_000; + +/** Strip ANSI escape sequences so we can parse plain key-value lines. */ +function stripAnsi(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); +} + +/** + * Extract a value from `agent about` key-value output. + * Lines look like: `CLI Version 2026.03.20-44cb435` + */ +function extractAboutField(plain: string, key: string): string | undefined { + const regex = new RegExp(`^${key}\\s{2,}(.+)$`, "mi"); + const match = regex.exec(plain); + return match?.[1]?.trim(); +} + +export interface CursorAboutResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +function joinProviderMessages(...messages: ReadonlyArray): string | undefined { + const parts = messages + .map((message) => message?.trim()) + .filter((message): message is string => Boolean(message)); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +export function buildCursorProviderSnapshot(input: { + readonly checkedAt: string; + readonly cursorSettings: CursorSettings; + readonly parsed: CursorAboutResult; + readonly discoveredModels?: ReadonlyArray; + readonly discoveryWarning?: string; +}): ServerProviderDraft { + const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: input.cursorSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels ?? [], + PROVIDER, + input.cursorSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.parsed.version, + status: + input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, + auth: input.parsed.auth, + ...(message ? { message } : {}), + }, + }); +} + +interface CursorAboutJsonPayload { + readonly cliVersion?: unknown; + readonly subscriptionTier?: unknown; + readonly userEmail?: unknown; +} + +export function parseCursorVersionDate(version: string | null | undefined): number | undefined { + const match = version?.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})(?:\b|-|$)/); + if (!match) { + return undefined; + } + const [, year, month, day] = match; + return Number(`${year}${month}${day}`); +} + +export function parseCursorCliConfigChannel(raw: string): string | undefined { + try { + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + "channel" in parsed && + typeof parsed.channel === "string" + ) { + const channel = parsed.channel.trim().toLowerCase(); + return channel.length > 0 ? channel : undefined; + } + } catch { + return undefined; + } + return undefined; +} + +function toTitleCaseWords(value: string): string { + return value + .split(/[\s_-]+/g) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +} + +function cursorSubscriptionLabel(subscriptionType: string | undefined): string | undefined { + const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); + if (!normalized) return undefined; + + switch (normalized) { + case "team": + return "Team"; + case "pro": + return "Pro"; + case "free": + return "Free"; + case "business": + return "Business"; + case "enterprise": + return "Enterprise"; + default: + return toTitleCaseWords(subscriptionType!); + } +} + +function cursorAuthMetadata( + subscriptionType: string | undefined, +): Pick | undefined { + if (!subscriptionType) { + return undefined; + } + const subscriptionLabel = cursorSubscriptionLabel(subscriptionType); + return { + type: subscriptionType, + label: `Cursor ${subscriptionLabel ?? toTitleCaseWords(subscriptionType)} Subscription`, + }; +} + +function parseCursorAboutJsonPayload(raw: string): CursorAboutJsonPayload | undefined { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) { + return undefined; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + return parsed as CursorAboutJsonPayload; + } catch { + return undefined; + } +} + +function hasOwn(record: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return ( + lowerOutput.includes("unknown option '--format'") || + lowerOutput.includes("unexpected argument '--format'") || + lowerOutput.includes("unrecognized option '--format'") || + lowerOutput.includes("unknown argument '--format'") + ); +} + +const readCursorCliConfigChannel = Effect.fn("readCursorCliConfigChannel")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(NodeOs.homedir(), ".cursor", "cli-config.json"); + const raw = yield* fileSystem.readFileString(configPath).pipe(Effect.orElseSucceed(() => "")); + return parseCursorCliConfigChannel(raw); +}); + +export function getCursorParameterizedModelPickerUnsupportedMessage(input: { + readonly version: string | null | undefined; + readonly channel: string | null | undefined; +}): string | undefined { + const reasons: Array = []; + const versionDate = parseCursorVersionDate(input.version); + if ( + versionDate !== undefined && + versionDate < CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE + ) { + reasons.push( + `Cursor Agent CLI version ${input.version} is too old for Cursor ACP parameterized model picker`, + ); + } + + const normalizedChannel = input.channel?.trim().toLowerCase(); + if ( + normalizedChannel !== undefined && + normalizedChannel.length > 0 && + normalizedChannel !== "lab" + ) { + reasons.push( + `Cursor Agent CLI channel is ${JSON.stringify(input.channel)}, but parameterized model picker is only available on the lab channel`, + ); + } + + if (reasons.length === 0) { + return undefined; + } + + return `${reasons.join(". ")}. Run \`agent set-channel lab && agent update\` and use Cursor Agent CLI 2026.04.08 or newer.`; +} + +/** + * Parse the output of `agent about` to extract version and authentication + * status in a single probe. + * + * Example output (logged in): + * ``` + * About Cursor CLI + * + * CLI Version 2026.03.20-44cb435 + * User Email user@example.com + * ``` + * + * Example output (logged out): + * ``` + * About Cursor CLI + * + * CLI Version 2026.03.20-44cb435 + * User Email Not logged in + * ``` + */ +export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult { + const jsonPayload = parseCursorAboutJsonPayload(result.stdout); + if (jsonPayload) { + const version = + typeof jsonPayload.cliVersion === "string" ? jsonPayload.cliVersion.trim() : null; + const hasUserEmailField = hasOwn(jsonPayload, "userEmail"); + const userEmail = + typeof jsonPayload.userEmail === "string" ? jsonPayload.userEmail.trim() : undefined; + const subscriptionType = + typeof jsonPayload.subscriptionTier === "string" + ? jsonPayload.subscriptionTier.trim() + : undefined; + const authMetadata = cursorAuthMetadata(subscriptionType); + + if (hasUserEmailField && jsonPayload.userEmail == null) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; + } + + if (!userEmail) { + if (result.code === 0) { + return { + version, + status: "ready", + auth: { + status: "unknown", + ...authMetadata, + }, + }; + } + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Cursor Agent authentication status.", + }; + } + + const lowerEmail = userEmail.toLowerCase(); + if ( + lowerEmail === "not logged in" || + lowerEmail.includes("login required") || + lowerEmail.includes("authentication required") + ) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; + } + + return { + version, + status: "ready", + auth: { + status: "authenticated", + email: userEmail, + ...authMetadata, + }, + }; + } + + const combined = `${result.stdout}\n${result.stderr}`; + const lowerOutput = combined.toLowerCase(); + + // If the command itself isn't recognised, we're on an old CLI version. + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "The `agent about` command is unavailable in this version of the Cursor Agent CLI.", + }; + } + + const plain = stripAnsi(combined); + const version = extractAboutField(plain, "CLI Version") ?? null; + const userEmail = extractAboutField(plain, "User Email"); + + // Determine auth from the User Email field. + if (userEmail === undefined) { + // Field missing entirely — can't determine auth. + if (result.code === 0) { + return { version, status: "ready", auth: { status: "unknown" } }; + } + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Cursor Agent authentication status.", + }; + } + + const lowerEmail = userEmail.toLowerCase(); + if ( + lowerEmail === "not logged in" || + lowerEmail.includes("login required") || + lowerEmail.includes("authentication required") + ) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; + } + + // Any non-empty email value means authenticated. + return { + version, + status: "ready", + auth: { status: "authenticated", email: userEmail }, + }; +} + +const runCursorCommand = ( + cursorSettings: CursorSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { + env: environment, + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +const runCursorAboutCommand = ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const jsonResult = yield* runCursorCommand( + cursorSettings, + ["about", "--format", "json"], + environment, + ); + if (!isCursorAboutJsonFormatUnsupported(jsonResult)) { + return jsonResult; + } + return yield* runCursorCommand(cursorSettings, ["about"], environment); + }); + +export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getCursorFallbackModels(cursorSettings); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + // Single `agent about` probe: returns version + auth status in one call. + const aboutProbe = yield* runCursorAboutCommand(cursorSettings, environment).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(aboutProbe)) { + const error = aboutProbe.failure; + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." + : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(aboutProbe.success)) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Cursor Agent CLI is installed but timed out while running `agent about`.", + }, + }); + } + + const parsed = parseCursorAboutOutput(aboutProbe.success.value); + const cursorCliConfigChannel = yield* readCursorCliConfigChannel(); + const parameterizedModelPickerUnsupportedMessage = + getCursorParameterizedModelPickerUnsupportedMessage({ + version: parsed.version, + channel: cursorCliConfigChannel, + }); + if (parameterizedModelPickerUnsupportedMessage) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: parsed.version, + status: "error", + auth: parsed.auth, + message: + parsed.auth.status === "unauthenticated" && parsed.message + ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` + : parameterizedModelPickerUnsupportedMessage, + }, + }); + } + let discoveredModels = Option.none>(); + let discoveryWarning: string | undefined; + if (parsed.auth.status !== "unauthenticated") { + const discoveryExit = yield* Effect.exit( + discoverCursorModelsViaAcp(cursorSettings, environment).pipe( + Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + ), + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Cursor ACP model discovery failed", { + cause: Cause.pretty(discoveryExit.cause), + }); + discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; + } else if (Option.isNone(discoveryExit.value)) { + discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; + } else if (discoveryExit.value.value.length === 0) { + discoveryWarning = "Cursor ACP model discovery returned no built-in models."; + } else { + discoveredModels = discoveryExit.value; + } + } + return buildCursorProviderSnapshot({ + checkedAt, + cursorSettings, + parsed, + discoveredModels: Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => [] as const, + ), + ...(discoveryWarning ? { discoveryWarning } : {}), + }); +}); + +export function hasUncapturedCursorModels(snapshot: Pick): boolean { + return snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)); +} + +/** + * Background capability enrichment for a Cursor snapshot. + * + * Used by `CursorDriver` as the `makeManagedServerProvider.enrichSnapshot` + * hook: runs the slow ACP per-model capability probe, and republishes the + * snapshot through `publishSnapshot` when new capabilities arrive. Skips + * the probe when the provider is disabled, unauthenticated, or has no + * uncaptured models. Keeps `EMPTY_CAPABILITIES` and the `PROVIDER` literal + * private to this module. + */ +export const enrichCursorSnapshot = (input: { + readonly settings: CursorSettings; + readonly environment?: NodeJS.ProcessEnv; + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { settings, snapshot, publishSnapshot } = input; + const stampIdentity = input.stampIdentity ?? ((value) => value); + + const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory( + snapshot, + input.maintenanceCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), + ), + Effect.catchCause((cause) => + Effect.logWarning("Cursor version advisory enrichment failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(snapshot)), + ), + ); + + return enrichVersionAdvisory.pipe( + Effect.flatMap((baseSnapshot) => { + if ( + !settings.enabled || + baseSnapshot.auth.status === "unauthenticated" || + !hasUncapturedCursorModels(baseSnapshot) + ) { + return Effect.void; + } + + return discoverCursorModelCapabilitiesViaAcp( + settings, + baseSnapshot.models, + input.environment, + ).pipe( + Effect.flatMap((discoveredModels) => { + if (discoveredModels.length === 0) { + return Effect.void; + } + return publishSnapshot( + stampIdentity({ + ...baseSnapshot, + models: providerModelsFromSettings( + discoveredModels, + PROVIDER, + settings.customModels, + EMPTY_CAPABILITIES, + ), + }), + ); + }), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP background capability enrichment failed", { + models: baseSnapshot.models.map((model) => model.slug), + cause: Cause.pretty(cause), + }).pipe(Effect.asVoid), + ), + ); + }), + ); +}; diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index aa7e5a26927..0b1f99d3c11 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -1,10 +1,11 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index 2a512efe660..3b36cc20c90 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off /** * Provider event logger helper. * @@ -10,7 +11,11 @@ import path from "node:path"; import type { ThreadId } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; -import { Effect, Exit, Logger, Scope, SynchronizedRef } from "effect"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Logger from "effect/Logger"; +import * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts new file mode 100644 index 00000000000..66dc5edc671 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -0,0 +1,840 @@ +import assert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Context from "effect/Context"; +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 Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { beforeEach } from "vitest"; + +import { + OpenCodeSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../opencodeRuntime.ts"; +import { + appendOpenCodeAssistantTextDelta, + makeOpenCodeAdapter, + mergeOpenCodeAssistantText, +} from "./OpenCodeAdapter.ts"; + +// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. +class OpenCodeAdapter extends Context.Service()( + "test/OpenCodeAdapter", +) {} + +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +type MessageEntry = { + info: { + id: string; + role: "user" | "assistant"; + }; + parts: Array; +}; + +const runtimeMock = { + state: { + startCalls: [] as string[], + sessionCreateUrls: [] as string[], + authHeaders: [] as Array, + abortCalls: [] as string[], + closeCalls: [] as string[], + revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + promptCalls: [] as Array, + promptAsyncError: null as Error | null, + closeError: null as Error | null, + messages: [] as MessageEntry[], + subscribedEvents: [] as unknown[], + }, + reset() { + this.state.startCalls.length = 0; + this.state.sessionCreateUrls.length = 0; + this.state.authHeaders.length = 0; + this.state.abortCalls.length = 0; + this.state.closeCalls.length = 0; + this.state.revertCalls.length = 0; + this.state.promptCalls.length = 0; + this.state.promptAsyncError = null; + this.state.closeError = null; + this.state.messages = []; + this.state.subscribedEvents = []; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ binaryPath }) => + Effect.gen(function* () { + runtimeMock.state.startCalls.push(binaryPath); + const url = "http://127.0.0.1:4301"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + if (runtimeMock.state.closeError) { + throw runtimeMock.state.closeError; + } + }), + ); + return { + url, + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: ({ serverUrl }) => + Effect.gen(function* () { + const url = serverUrl ?? "http://127.0.0.1:4301"; + // Unconditionally register a scope finalizer for test observability — + // preserves the `closeCalls` / `closeError` probes that the existing + // suites rely on. Production code never attaches a finalizer to an + // external server (it simply returns `Effect.succeed(...)`). + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + if (runtimeMock.state.closeError) { + throw runtimeMock.state.closeError; + } + }), + ); + return { + url, + exitCode: null, + external: Boolean(serverUrl), + }; + }), + runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + ({ + session: { + create: async () => { + runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return { data: { id: `${baseUrl}/session` } }; + }, + abort: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.abortCalls.push(sessionID); + }, + promptAsync: async (input: unknown) => { + runtimeMock.state.promptCalls.push(input); + if (runtimeMock.state.promptAsyncError) { + throw runtimeMock.state.promptAsyncError; + } + }, + messages: async () => ({ data: runtimeMock.state.messages }), + revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { + runtimeMock.state.revertCalls.push({ + sessionID, + ...(messageID ? { messageID } : {}), + }); + if (!messageID) { + runtimeMock.state.messages = []; + return; + } + + const targetIndex = runtimeMock.state.messages.findIndex( + (entry) => entry.info.id === messageID, + ); + runtimeMock.state.messages = + targetIndex >= 0 + ? runtimeMock.state.messages.slice(0, targetIndex + 1) + : runtimeMock.state.messages; + }, + }, + event: { + subscribe: async () => ({ + stream: (async function* () { + for (const event of runtimeMock.state.subscribedEvents) { + yield event; + } + })(), + }), + }, + }) as unknown as ReturnType, + loadOpenCodeInventory: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", + cause: null, + }), + ), +}; + +const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { + upsert: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), +}); + +// The adapter now receives its settings as a plain argument (the old design +// read from `ServerSettingsService` internally). The test-only +// `ServerSettingsService` below is still kept because other dependencies in +// the layer graph reach for it — but the routing values the assertions +// probe (serverUrl, serverPassword) must be threaded directly through the +// decoded `OpenCodeSettings`. +const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", +}); + +const OpenCodeAdapterTestLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings), +).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), +); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const advanceTestClock = (ms: number) => + TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); + +it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "opencode"); + assert.equal(session.threadId, "thread-opencode"); + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + assert.deepEqual(runtimeMock.state.authHeaders, [ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ); + + it.effect("stops a configured-server session without trying to own server lifecycle", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + yield* adapter.stopSession(asThreadId("thread-opencode")); + + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual( + runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), + true, + ); + }), + ); + + it.effect("emits one session.exited event when stopping a session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stop-event"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.stopSession(threadId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + assert.deepEqual( + events.map((event) => event.type), + ["session.started", "thread.started", "session.exited"], + ); + }), + ); + + it.effect("clears session state even when cleanup finalizers throw", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-stop-all-a"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-stop-all-b"), + runtimeMode: "full-access", + }); + + runtimeMock.state.closeError = new Error("close failed"); + // `stopAll` relies on `stopOpenCodeContext`, which is typed as + // never-failing. A throwing finalizer surfaces as a defect — `Effect.exit` + // captures it so the assertions can still run. The key invariant we're + // validating is "the sessions map and close-call probes reflect cleanup + // attempts regardless of finalizer outcome". + yield* Effect.exit(adapter.stopAll()); + const sessions = yield* adapter.listSessions(); + + assert.deepEqual(runtimeMock.state.closeCalls, [ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + assert.deepEqual(sessions, []); + }), + ); + + it.effect("completes streamEvents when the adapter scope closes", () => + Effect.gen(function* () { + const scope = yield* Scope.make("sequential"); + let scopeClosed = false; + + try { + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + const context = yield* Layer.buildWithScope(adapterLayer, scope); + const adapter = yield* Effect.service(OpenCodeAdapter).pipe(Effect.provide(context)); + const eventsFiber = yield* adapter.streamEvents.pipe(Stream.runCollect, Effect.forkChild); + + yield* Scope.close(scope, Exit.void); + scopeClosed = true; + + const exit = yield* Fiber.await(eventsFiber).pipe(Effect.timeout("1 second")); + assert.equal(Exit.hasInterrupts(exit), true); + } finally { + if (!scopeClosed) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + } + }), + ); + + it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-send-turn-failure"), + runtimeMode: "full-access", + }); + + runtimeMock.state.promptAsyncError = new Error("prompt failed"); + const error = yield* adapter + .sendTurn({ + threadId: asThreadId("thread-send-turn-failure"), + input: "Fix it", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }) + .pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag !== "ProviderAdapterRequestError") { + throw new Error("Unexpected error type"); + } + assert.equal(error.detail, "prompt failed"); + assert.equal( + error.message, + "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", + ); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.status, "ready"); + assert.equal(sessions[0]?.activeTurnId, undefined); + assert.equal(sessions[0]?.lastError, "prompt failed"); + }), + ); + + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-custom-instance"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: asThreadId("thread-custom-instance"), + input: "Fix it", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + [ + { id: "agent", value: "github-copilot" }, + { id: "variant", value: "high" }, + ], + ), + }); + + assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + agent: "github-copilot", + variant: "high", + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-custom-instance-fallback-model"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + ), + }); + + yield* adapter.sendTurn({ + threadId, + input: "Fix it", + }); + + assert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("rejects sendTurn model selections for another instance id", () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-custom-instance-wrong-selection"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const error = yield* adapter + .sendTurn({ + threadId, + input: "Fix it", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/claude-sonnet-4-5", + ), + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag !== "ProviderAdapterValidationError") { + throw new Error("Unexpected error type"); + } + assert.equal( + error.issue, + "OpenCode model selection is bound to instance 'opencode', expected 'opencode_zen'.", + ); + assert.deepEqual(runtimeMock.state.promptCalls, []); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("reverts the full thread when rollback removes every assistant turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-rollback-all"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + runtimeMock.state.messages = [ + { + info: { id: "assistant-1", role: "assistant" }, + parts: [], + }, + { + info: { id: "assistant-2", role: "assistant" }, + parts: [], + }, + ]; + + const snapshot = yield* adapter.rollbackThread(threadId, 2); + + assert.deepEqual(runtimeMock.state.revertCalls, [ + { sessionID: "http://127.0.0.1:9999/session" }, + ]); + assert.deepEqual(snapshot.turns, []); + }), + ); + + it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => + Effect.sync(() => { + const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); + const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); + const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); + + assert.deepEqual( + [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], + ["Hello", "lo world", ""], + ); + assert.equal(secondUpdate.latestText, "Hellolo world"); + }), + ); + + it.effect("does not strip coincidental prefix overlap from OpenCode part deltas", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-raw-delta"); + const part = { + id: "part-raw-delta", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-raw-delta", + type: "text", + text: "A B", + time: { start: 1 }, + }; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-raw-delta", + role: "assistant", + }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part, + time: 1, + }, + }, + { + type: "message.part.delta", + properties: { + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-raw-delta", + partID: "part-raw-delta", + field: "text", + delta: "Bonus", + }, + }, + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...part, + text: "A BBonus", + time: { start: 1, end: 2 }, + }, + time: 2, + }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const deltas = events.filter((event) => event.type === "content.delta"); + assert.deepEqual( + deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : "")), + ["A B", "Bonus"], + ); + assert.equal(events.at(-1)?.type, "item.completed"); + const completed = events.at(-1); + if (completed?.type === "item.completed") { + assert.equal(completed.payload.detail, "A BBonus"); + } + }), + ); + + it.effect("writes provider-native observability records using the session thread id", () => + Effect.gen(function* () { + const nativeEvents: Array<{ + readonly event?: { + readonly provider?: string; + readonly threadId?: string; + readonly providerThreadId?: string; + readonly type?: string; + }; + }> = []; + const nativeThreadIds: Array = []; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + info: { + id: "msg-missing-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/other-session", + info: { + id: "msg-other-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: (event: unknown, threadId: ThreadId | null) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + nativeThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }; + + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { + nativeEventLogger, + }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const session = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const started = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-native-log"), + runtimeMode: "full-access", + }); + yield* advanceTestClock(10); + return started; + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(session.threadId, "thread-native-log"); + assert.equal(nativeEvents.length, 1); + assert.equal( + nativeEvents.some((record) => record.event?.provider === "opencode"), + true, + ); + assert.equal( + nativeEvents.some( + (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", + ), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.type === "message.updated"), + true, + ); + assert.equal( + nativeThreadIds.every((threadId) => threadId === "thread-native-log"), + true, + ); + }), + ); + + it.effect("keeps the event pump alive when native event logging fails", () => + Effect.gen(function* () { + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log-failure", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: () => Effect.die(new Error("native log write failed")), + close: () => Effect.void, + }; + + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { + nativeEventLogger, + }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + // Capture closeCalls *inside* the provided layer scope: the adapter's + // layer finalizer now tears down any live sessions when the layer + // closes (which is exactly what we want for leak prevention), so + // inspecting closeCalls after `Effect.provide` completes would observe + // the teardown — not the behavior under test. We care that the event + // pump kept the session alive while logging was failing. + const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-native-log-failure"), + runtimeMode: "full-access", + }); + yield* advanceTestClock(10); + return { + sessions: yield* adapter.listSessions(), + closeCallsDuringRun: [...runtimeMock.state.closeCalls], + }; + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); + assert.deepEqual(closeCallsDuringRun, []); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts new file mode 100644 index 00000000000..512e9ed6bfe --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -0,0 +1,1435 @@ +import { + EventId, + type OpenCodeSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { + buildOpenCodePermissionRules, + OpenCodeRuntime, + OpenCodeRuntimeError, + openCodeQuestionId, + openCodeRuntimeErrorDetail, + parseOpenCodeModelSlug, + runOpenCodeSdk, + toOpenCodeFileParts, + toOpenCodePermissionReply, + toOpenCodeQuestionAnswers, + type OpenCodeServerConnection, +} from "../opencodeRuntime.ts"; +import * as Option from "effect/Option"; + +const PROVIDER = ProviderDriverKind.make("opencode"); + +interface OpenCodeTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +type OpenCodeSubscribedEvent = + Awaited> extends { + readonly stream: AsyncIterable; + } + ? TEvent + : never; + +interface OpenCodeSessionContext { + session: ProviderSession; + readonly client: OpencodeClient; + readonly server: OpenCodeServerConnection; + readonly directory: string; + readonly openCodeSessionId: string; + readonly pendingPermissions: Map; + readonly pendingQuestions: Map; + readonly messageRoleById: Map; + readonly partById: Map; + readonly emittedTextByPartId: Map; + readonly completedAssistantPartIds: Set; + readonly turns: Array; + activeTurnId: TurnId | undefined; + activeAgent: string | undefined; + activeVariant: string | undefined; + /** + * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. + * The session lifecycle is owned by `sessionScope`; this Ref exists only + * so concurrent callers can race the transition safely via `getAndSet`. + */ + readonly stopped: Ref.Ref; + /** + * Sole lifecycle handle for the session. Closing this scope: + * - aborts the `AbortController` registered as a finalizer + * (cancels the in-flight `event.subscribe` fetch), + * - interrupts the event-pump and server-exit fibers forked + * via `Effect.forkIn(sessionScope)`, + * - tears down the OpenCode server process for scope-owned servers. + */ + readonly sessionScope: Scope.Closeable; +} + +export interface OpenCodeAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +/** + * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into + * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call + * sites pipe through this in `Effect.mapError` so they never build the error + * shape by hand. + */ +const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: cause.operation, + detail: cause.detail, + cause: cause.cause, + }); + +/** + * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The + * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}), + * in which case we preserve its `detail`; otherwise we fall back to + * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). + */ +const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), + cause, + }); + +const buildEventBase = (input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly createdAt?: string | undefined; + readonly raw?: unknown; +}): Effect.Effect< + Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" + > +> => + Effect.gen(function* () { + const uuid = yield* Random.nextUUIDv4; + const createdAt = input.createdAt ?? (yield* nowIso); + return { + eventId: EventId.make(uuid), + provider: PROVIDER, + threadId: input.threadId, + createdAt, + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw !== undefined + ? { + raw: { + source: "opencode.sdk.event", + payload: input.raw, + }, + } + : {}), + }; + }); + +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if (normalized.includes("bash") || normalized.includes("command")) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("multiedit") + ) { + return "file_change"; + } + if (normalized.includes("web")) { + return "web_search"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if (normalized.includes("image")) { + return "image_view"; + } + if ( + normalized.includes("task") || + normalized.includes("agent") || + normalized.includes("subtask") + ) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +function mapPermissionToRequestType( + permission: string, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (permission) { + case "bash": + return "command_execution_approval"; + case "read": + return "file_read_approval"; + case "edit": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function mapPermissionDecision(reply: "once" | "always" | "reject"): string { + switch (reply) { + case "once": + return "accept"; + case "always": + return "acceptForSession"; + case "reject": + default: + return "decline"; + } +} + +function resolveTurnSnapshot( + context: OpenCodeSessionContext, + turnId: TurnId, +): OpenCodeTurnSnapshot { + const existing = context.turns.find((turn) => turn.id === turnId); + if (existing) { + return existing; + } + + const created: OpenCodeTurnSnapshot = { id: turnId, items: [] }; + context.turns.push(created); + return created; +} + +function appendTurnItem( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, + item: unknown, +): void { + if (!turnId) { + return; + } + resolveTurnSnapshot(context, turnId).items.push(item); +} + +function ensureSessionContext( + sessions: ReadonlyMap, + threadId: ThreadId, +): OpenCodeSessionContext { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + // `ensureSessionContext` is a sync gate used from both sync helpers and + // Effect bodies. `Ref.getUnsafe` is an atomic read of the backing cell — + // no fiber suspension required, which keeps this callable everywhere. + if (Ref.getUnsafe(session.stopped)) { + throw new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }); + } + return session; +} + +function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray { + return request.questions.map((question, index) => ({ + id: openCodeQuestionId(index, question), + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + ...(question.multiple ? { multiSelect: true } : {}), + })); +} + +function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { + return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; +} + +function textFromPart(part: Part): string | undefined { + switch (part.type) { + case "text": + case "reasoning": + return part.text; + default: + return undefined; + } +} + +function commonPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string { + if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) { + return previousText; + } + return nextText; +} + +export function mergeOpenCodeAssistantText( + previousText: string | undefined, + nextText: string, +): { + readonly latestText: string; + readonly deltaToEmit: string; +} { + const latestText = resolveLatestAssistantText(previousText, nextText); + return { + latestText, + deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + }; +} + +export function appendOpenCodeAssistantTextDelta( + previousText: string, + delta: string, +): { + readonly nextText: string; + readonly deltaToEmit: string; +} { + return { + nextText: previousText + delta, + deltaToEmit: delta, + }; +} + +const isoFromEpochMs = (value: number) => + DateTime.make(value).pipe( + Option.match({ + onNone: () => undefined, + onSome: DateTime.formatIso, + }), + ); + +function messageRoleForPart( + context: OpenCodeSessionContext, + part: Pick, +): "assistant" | "user" | undefined { + const known = context.messageRoleById.get(part.messageID); + if (known) { + return known; + } + return part.type === "tool" ? "assistant" : undefined; +} + +function detailFromToolPart(part: Extract): string | undefined { + switch (part.state.status) { + case "completed": + return part.state.output; + case "error": + return part.state.error; + case "running": + return part.state.title; + default: + return undefined; + } +} + +function toolStateCreatedAt(part: Extract): string | undefined { + switch (part.state.status) { + case "running": + return isoFromEpochMs(part.state.time.start); + case "completed": + case "error": + return isoFromEpochMs(part.state.time.end); + default: + return undefined; + } +} + +function sessionErrorMessage(error: unknown): string { + if (!error || typeof error !== "object") { + return "OpenCode session failed."; + } + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; + const message = data && "message" in data ? data.message : null; + return typeof message === "string" && message.trim().length > 0 + ? message + : "OpenCode session failed."; +} + +function updateProviderSession( + context: OpenCodeSessionContext, + patch: Partial, + options?: { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + }, +): Effect.Effect { + return Effect.gen(function* () { + const updatedAt = yield* nowIso; + const nextSession = { + ...context.session, + ...patch, + updatedAt, + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; + }); +} + +const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( + context: OpenCodeSessionContext, +) { + // Race-safe one-shot: first caller flips the flag, everyone else no-ops. + if (yield* Ref.getAndSet(context.stopped, true)) { + return false; + } + + // Best-effort remote abort. The scope close below tears down the local + // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), + // but we still want to tell OpenCode that this session is done. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.ignore({ log: true })); + + // Closing the session scope interrupts every fiber forked into it and + // runs each finalizer we registered — the `AbortController.abort()` call, + // the child-process termination, etc. + yield* Scope.close(context.sessionScope, Exit.void); + return true; +}); + +export function makeOpenCodeAdapter( + openCodeSettings: OpenCodeSettings, + options?: OpenCodeAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); + const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + // Only close loggers we created. If the caller passed one in via + // `options.nativeEventLogger`, they own its lifecycle. + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + + // Layer-level finalizer: when the adapter layer shuts down, stop every + // session. Each session's `Scope.close` tears down its spawned OpenCode + // server (via the `ChildProcessSpawner` finalizer installed in + // `startOpenCodeServerProcess`) and interrupts the forked event/exit + // fibers. Consumers that can't reason about Effect scopes therefore + // cannot leak OpenCode child processes by forgetting to call `stopAll`. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + // `ignoreCause` swallows both typed failures (none here) and defects + // from throwing scope finalizers so a sibling's death can't interrupt + // the remaining cleanups. + yield* Effect.forEach( + contexts, + (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + { concurrency: "unbounded", discard: true }, + ); + // Close the logger AFTER session teardown so any final lifecycle + // events emitted during shutdown still get written. `close` flushes + // the `Logger.batched` window and closes each per-thread + // `RotatingFileSink` handle owned by the logger's internal scope. + if (managedNativeEventLogger !== undefined) { + yield* managedNativeEventLogger.close(); + } + }).pipe(Effect.ensuring(Queue.shutdown(runtimeEvents))), + ); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const writeNativeEvent = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); + const writeNativeEventBestEffort = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + + const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( + context: OpenCodeSessionContext, + message: string, + ) { + // Atomic one-shot: two fibers can race here (the event-pump on stream + // failure and the server-exit watcher). `getAndSet` flips the flag in + // a single step so the loser observes `true` and returns; a plain + // `Ref.get` would let both racers slip past and emit duplicates. + if (yield* Ref.getAndSet(context.stopped, true)) { + return; + } + const turnId = context.activeTurnId; + sessions.delete(context.session.threadId); + // Emit lifecycle events BEFORE tearing down the scope. Both call sites + // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; + // closing that scope triggers the fiber-interrupt finalizer, so any + // subsequent yield point would unwind and silently drop these emits. + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + })), + type: "runtime.error", + payload: { + message, + class: "transport_error", + }, + }).pipe(Effect.ignore); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + })), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", + }, + }).pipe(Effect.ignore); + // Inline the teardown that `stopOpenCodeContext` would do; we can't + // delegate to it because our `getAndSet` above already flipped the + // one-shot guard, so the call would no-op. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.ignore({ log: true })); + yield* Scope.close(context.sessionScope, Exit.void); + }); + + /** Emit content.delta and item.completed events for an assistant text part. */ + const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( + context: OpenCodeSessionContext, + part: Part, + turnId: TurnId | undefined, + raw: unknown, + ) { + const text = textFromPart(part); + if (text === undefined) { + return; + } + const previousText = context.emittedTextByPartId.get(part.id); + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); + context.emittedTextByPartId.set(part.id, latestText); + if (latestText !== text) { + context.partById.set( + part.id, + (part.type === "text" || part.type === "reasoning" + ? { ...part, text: latestText } + : part) satisfies Part, + ); + } + if (deltaToEmit.length > 0) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: + (part.type === "text" || part.type === "reasoning") && part.time !== undefined + ? isoFromEpochMs(part.time.start) + : undefined, + raw, + })), + type: "content.delta", + payload: { + streamKind: resolveTextStreamKind(part), + delta: deltaToEmit, + }, + }); + } + + if ( + part.type === "text" && + part.time?.end !== undefined && + !context.completedAssistantPartIds.has(part.id) + ) { + context.completedAssistantPartIds.add(part.id); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw, + })), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(latestText.length > 0 ? { detail: latestText } : {}), + }, + }); + } + }); + + const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeSubscribedEvent, + ) { + const payloadSessionId = + "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined; + if (payloadSessionId !== context.openCodeSessionId) { + return; + } + + const turnId = context.activeTurnId; + yield* writeNativeEventBestEffort(context.session.threadId, { + observedAt: yield* nowIso, + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.openCodeSessionId, + type: event.type, + ...(turnId ? { turnId } : {}), + payload: event, + }, + }); + + switch (event.type) { + case "message.updated": { + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "assistant") { + for (const part of context.partById.values()) { + if (part.messageID !== event.properties.info.id) { + continue; + } + yield* emitAssistantTextDelta(context, part, turnId, event); + } + } + break; + } + + case "message.removed": { + context.messageRoleById.delete(event.properties.messageID); + break; + } + + case "message.part.delta": { + const existingPart = context.partById.get(event.properties.partID); + if (!existingPart) { + break; + } + const role = messageRoleForPart(context, existingPart); + if (role !== "assistant") { + break; + } + const streamKind = resolveTextStreamKind(existingPart); + const delta = event.properties.delta; + if (delta.length === 0) { + break; + } + const previousText = + context.emittedTextByPartId.get(event.properties.partID) ?? + textFromPart(existingPart) ?? + ""; + const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); + if (deltaToEmit.length === 0) { + break; + } + context.emittedTextByPartId.set(event.properties.partID, nextText); + if (existingPart.type === "text" || existingPart.type === "reasoning") { + context.partById.set(event.properties.partID, { + ...existingPart, + text: nextText, + }); + } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + raw: event, + })), + type: "content.delta", + payload: { + streamKind, + delta: deltaToEmit, + }, + }); + break; + } + + case "message.part.updated": { + const part = event.properties.part; + context.partById.set(part.id, part); + const messageRole = messageRoleForPart(context, part); + + if (messageRole === "assistant") { + yield* emitAssistantTextDelta(context, part, turnId, event); + } + + if (part.type === "tool") { + const itemType = toToolLifecycleItemType(part.tool); + const title = + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + })), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + yield* emit(runtimeEvent); + } + break; + } + + case "permission.asked": { + context.pendingPermissions.set(event.properties.id, event.properties); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + })), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(event.properties.permission), + detail: + event.properties.patterns.length > 0 + ? event.properties.patterns.join("\n") + : event.properties.permission, + args: event.properties.metadata, + }, + }); + break; + } + + case "permission.replied": { + context.pendingPermissions.delete(event.properties.requestID); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + })), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + break; + } + + case "question.asked": { + context.pendingQuestions.set(event.properties.id, event.properties); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + })), + type: "user-input.requested", + payload: { + questions: normalizeQuestionRequest(event.properties), + }, + }); + break; + } + + case "question.replied": { + const request = context.pendingQuestions.get(event.properties.requestID); + context.pendingQuestions.delete(event.properties.requestID); + const answers = Object.fromEntries( + (request?.questions ?? []).map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + })), + type: "user-input.resolved", + payload: { answers }, + }); + break; + } + + case "question.rejected": { + context.pendingQuestions.delete(event.properties.requestID); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + })), + type: "user-input.resolved", + payload: { answers: {} }, + }); + break; + } + + case "session.status": { + if (event.properties.status.type === "busy") { + yield* updateProviderSession(context, { + status: "running", + activeTurnId: turnId, + }); + } + + if (event.properties.status.type === "retry") { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + })), + type: "runtime.warning", + payload: { + message: event.properties.status.message, + detail: event.properties.status, + }, + }); + break; + } + + if (event.properties.status.type === "idle" && turnId) { + context.activeTurnId = undefined; + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + })), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + } + break; + } + + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + const activeTurnId = context.activeTurnId; + context.activeTurnId = undefined; + yield* updateProviderSession( + context, + { + status: "error", + lastError: message, + }, + { clearActiveTurnId: true }, + ); + if (activeTurnId) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: activeTurnId, + raw: event, + })), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: message, + }, + }); + } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + raw: event, + })), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.properties.error, + }, + }); + break; + } + + default: + break; + } + }); + + const startEventPump = Effect.fn("startEventPump")(function* (context: OpenCodeSessionContext) { + // One AbortController per session scope. The finalizer fires when + // the scope closes (explicit stop, unexpected exit, or layer + // shutdown) and cancels the in-flight `event.subscribe` fetch so + // the async iterable unwinds cleanly. + const eventsAbortController = new AbortController(); + yield* Scope.addFinalizer( + context.sessionScope, + Effect.sync(() => eventsAbortController.abort()), + ); + + // Fibers forked into `context.sessionScope` are interrupted + // automatically when the scope closes — no bookkeeping required. + yield* Effect.flatMap( + runOpenCodeSdk("event.subscribe", () => + context.client.event.subscribe(undefined, { + signal: eventsAbortController.signal, + }), + ), + (subscription) => + Stream.fromAsyncIterable( + subscription.stream, + (cause) => + new OpenCodeRuntimeError({ + operation: "event.subscribe", + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), + ).pipe( + Effect.exit, + Effect.flatMap((exit) => + Effect.gen(function* () { + // Expected paths: caller aborted the fetch or the session + // has already been marked stopped. Treat as a clean exit. + if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { + return; + } + if (Exit.isFailure(exit)) { + yield* emitUnexpectedExit( + context, + openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), + ); + } + }), + ), + Effect.forkIn(context.sessionScope), + ); + + if (!context.server.external && context.server.exitCode !== null) { + yield* context.server.exitCode.pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + if (yield* Ref.get(context.stopped)) { + return; + } + yield* emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`); + }), + ), + Effect.forkIn(context.sessionScope), + ); + } + }); + + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + const binaryPath = openCodeSettings.binaryPath; + const serverUrl = openCodeSettings.serverUrl; + const serverPassword = openCodeSettings.serverPassword; + const directory = input.cwd ?? serverConfig.cwd; + const existing = sessions.get(input.threadId); + if (existing) { + yield* stopOpenCodeContext(existing); + sessions.delete(input.threadId); + } + + const started = yield* Effect.gen(function* () { + const sessionScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + Effect.gen(function* () { + // The runtime binds the server's lifetime to the Scope.Scope + // we provide below — closing `sessionScope` kills the child + // process automatically. No manual `server.close()` needed. + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath, + serverUrl, + ...(options?.environment ? { environment: options.environment } : {}), + }); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory, + ...(server.external && serverPassword ? { serverPassword } : {}), + }); + const openCodeSession = yield* runOpenCodeSdk("session.create", () => + client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + if (!openCodeSession.data) { + return yield* new OpenCodeRuntimeError({ + operation: "session.create", + detail: "OpenCode session.create returned no session payload.", + }); + } + return { + sessionScope, + server, + client, + openCodeSession: openCodeSession.data, + }; + }).pipe(Effect.provideService(Scope.Scope, sessionScope)), + ); + if (Exit.isFailure(startedExit)) { + yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); + return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); + } + return startedExit.value; + }); + + // Guard against a concurrent startSession call that may have raced + // and already inserted a session while we were awaiting async work. + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another call won the race – clean up the session we just created + // (including the remote SDK session) and return the existing one. + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSession.id, + }), + ).pipe(Effect.ignore); + yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); + return raceWinner.session; + } + + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(input.modelSelection ? { model: input.modelSelection.model } : {}), + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; + + const context: OpenCodeSessionContext = { + session, + client: started.client, + server: started.server, + directory, + openCodeSessionId: started.openCodeSession.id, + pendingPermissions: new Map(), + pendingQuestions: new Map(), + partById: new Map(), + emittedTextByPartId: new Map(), + messageRoleById: new Map(), + completedAssistantPartIds: new Set(), + turns: [], + activeTurnId: undefined, + activeAgent: undefined, + activeVariant: undefined, + stopped: yield* Ref.make(false), + sessionScope: started.sessionScope, + }; + sessions.set(input.threadId, context); + yield* startEventPump(context); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.started", + payload: { + message: "OpenCode session started", + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { + providerThreadId: started.openCodeSession.id, + }, + }); + + return session; + }, + ); + + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = ensureSessionContext(sessions, input.threadId); + const turnId = TurnId.make(`opencode-turn-${yield* Random.nextUUIDv4}`); + const modelSelection = + input.modelSelection ?? + (context.session.model + ? { instanceId: boundInstanceId, model: context.session.model } + : undefined); + if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`, + }); + } + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + if (!parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const text = input.input?.trim(); + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }), + }); + if ((!text || text.length === 0) && fileParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode turns require text input or at least one attachment.", + }); + } + + const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); + const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + yield* updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, + }); + + yield* runOpenCodeSdk("session.promptAsync", () => + context.client.session.promptAsync({ + sessionID: context.openCodeSessionId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }), + ).pipe( + Effect.mapError(toRequestError), + // On failure: clear active-turn state, flip the session back to ready + // with lastError set, emit turn.aborted, then let the typed error + // propagate. We don't need to rebuild the error here — `toRequestError` + // already produced the right shape. + Effect.tapError((requestError) => + Effect.gen(function* () { + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ + threadId: input.threadId, + turnId, + })), + type: "turn.aborted", + payload: { + reason: requestError.detail, + }, + }); + }), + ), + ); + + return { + threadId: input.threadId, + turnId, + }; + }); + + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = ensureSessionContext(sessions, threadId); + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); + if (turnId ?? context.activeTurnId) { + yield* emit({ + ...(yield* buildEventBase({ + threadId, + turnId: turnId ?? context.activeTurnId, + })), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); + + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (threadId, requestId, decision) { + const context = ensureSessionContext(sessions, threadId); + if (!context.pendingPermissions.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }), + ).pipe(Effect.mapError(toRequestError)); + }); + + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = ensureSessionContext(sessions, threadId); + const request = context.pendingQuestions.get(requestId); + if (!request) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + yield* runOpenCodeSdk("question.reply", () => + context.client.question.reply({ + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }), + ).pipe(Effect.mapError(toRequestError)); + }); + + const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + const context = sessions.get(threadId); + if (!context) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + const stopped = yield* stopOpenCodeContext(context); + sessions.delete(threadId); + if (!stopped) { + return; + } + yield* emit({ + ...(yield* buildEventBase({ threadId })), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }, + ); + + const listSessions: OpenCodeAdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)); + + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ + sessionID: context.openCodeSessionId, + }), + ).pipe(Effect.mapError(toRequestError)); + + const turns = (messages.data ?? []) + .filter((entry) => entry.info.role === "assistant") + .map((entry) => ({ + id: TurnId.make(entry.info.id), + items: [entry.info, ...entry.parts], + })); + + return { + threadId, + turns, + }; + }, + ); + + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, numTurns) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ + sessionID: context.openCodeSessionId, + }), + ).pipe(Effect.mapError(toRequestError)); + + const assistantMessages = (messages.data ?? []).filter( + (entry) => entry.info.role === "assistant", + ); + const targetIndex = assistantMessages.length - numTurns - 1; + const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; + yield* runOpenCodeSdk("session.revert", () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + ...(target ? { messageID: target.info.id } : {}), + }), + ).pipe(Effect.mapError(toRequestError)); + + return yield* readThread(threadId); + }, + ); + + const stopAll: OpenCodeAdapterShape["stopAll"] = () => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + // `stopOpenCodeContext` is typed as never-failing — SDK aborts are + // already `Effect.ignore`'d inside it. `ignoreCause` here also + // swallows defects from throwing finalizers so one bad close can't + // interrupt the sibling fibers. Same pattern as the layer finalizer. + yield* Effect.forEach( + contexts, + (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + { concurrency: "unbounded", discard: true }, + ); + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies OpenCodeAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts new file mode 100644 index 00000000000..e56806e26b5 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { beforeEach } from "vitest"; + +import { OpenCodeSettings } from "@t3tools/contracts"; +import { ServerConfig } from "../../config.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../opencodeRuntime.ts"; +import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; +import type { OpenCodeInventory } from "../opencodeRuntime.ts"; +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); + +const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; + +/** + * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag + * are deleted. The snapshot-producing logic they wrapped now lives in the + * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which + * drivers call directly when building their per-instance snapshot + * `ServerProviderShape`. Tests mirror that shape: build a settings payload, + * invoke the check, assert on the returned snapshot. + */ + +const runtimeMock = { + state: { + runVersionError: null as Error | null, + versionStdout: DEFAULT_VERSION_STDOUT, + inventoryError: null as Error | null, + closeCalls: 0, + inventory: { + providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + } as unknown, + }, + reset() { + this.state.runVersionError = null; + this.state.versionStdout = DEFAULT_VERSION_STDOUT; + this.state.inventoryError = null; + this.state.closeCalls = 0; + this.state.inventory = { + providerList: { connected: [], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + }; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.succeed({ + url: "http://127.0.0.1:4301", + exitCode: Effect.never, + }), + connectToOpenCodeServer: ({ serverUrl }) => + Effect.gen(function* () { + if (!serverUrl) { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + } + return { + url: serverUrl ?? "http://127.0.0.1:4301", + exitCode: null, + external: Boolean(serverUrl), + }; + }), + runOpenCodeCommand: () => + runtimeMock.state.runVersionError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: runtimeMock.state.runVersionError.message, + cause: runtimeMock.state.runVersionError, + }), + ) + : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), + createOpenCodeSdkClient: () => + ({}) as unknown as ReturnType, + loadOpenCodeInventory: () => + runtimeMock.state.inventoryError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), +}; + +beforeEach(() => { + runtimeMock.reset(); +}); + +const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), +); + +const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => + decodeOpenCodeSettings({ + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + ...overrides, + }); + +it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { + it.effect("shows a codex-style missing binary message", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH."); + }), + ); + + it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + }), + ); + + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + }, + }, + }, + }, + ], + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); + + assert.ok(model); + const variantDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "variant" && descriptor.type === "select", + ); + assert.ok(variantDescriptor && variantDescriptor.type === "select"); + assert.equal( + variantDescriptor.options.find((option) => option.isDefault === true)?.id, + "medium", + ); + const agentDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "agent" && descriptor.type === "select", + ); + assert.ok(agentDescriptor && agentDescriptor.type === "select"); + assert.equal( + agentDescriptor.options.find((option) => option.isDefault === true)?.id, + "build", + ); + }), + ); + + it.effect("closes the local OpenCode server scope after provider refresh", () => + Effect.gen(function* () { + yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + assert.equal(runtimeMock.state.closeCalls, 1); + }), + ); +}); + +it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect("surfaces a friendly auth error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("401 Unauthorized"); + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + process.cwd(), + ); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("surfaces a friendly connection error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error( + "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", + ); + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + process.cwd(), + ); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts new file mode 100644 index 00000000000..dea95c990d2 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -0,0 +1,474 @@ +import { + ProviderDriverKind, + type ModelCapabilities, + type OpenCodeSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { + buildServerProvider, + nonEmptyTrimmed, + parseGenericCliVersion, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + OpenCodeRuntime, + openCodeRuntimeErrorDetail, + type OpenCodeInventory, +} from "../opencodeRuntime.ts"; +import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; + +const PROVIDER = ProviderDriverKind.make("opencode"); +const OPENCODE_PRESENTATION = { + displayName: "OpenCode", + showInteractionModeToggle: false, +} as const; +const MINIMUM_OPENCODE_VERSION = "1.14.19"; + +class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ + readonly cause: unknown; + readonly detail: string; +}> {} + +function normalizeProbeMessage(message: string): string | undefined { + const trimmed = message.trim(); + if (trimmed.length === 0) { + return undefined; + } + if ( + trimmed === "An error occurred in Effect.tryPromise" || + trimmed === "An error occurred in Effect.try" + ) { + return undefined; + } + return trimmed; +} + +function normalizedErrorMessage(cause: unknown): string | undefined { + if (cause instanceof OpenCodeProbeError) { + return normalizeProbeMessage(cause.detail); + } + + if (!(cause instanceof Error)) { + return undefined; + } + + return normalizeProbeMessage(cause.message); +} + +function formatOpenCodeProbeError(input: { + readonly cause: unknown; + readonly isExternalServer: boolean; + readonly serverUrl: string; +}): { readonly installed: boolean; readonly message: string } { + const detail = normalizedErrorMessage(input.cause); + const lower = detail?.toLowerCase() ?? ""; + + if (input.isExternalServer) { + if ( + lower.includes("401") || + lower.includes("403") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return { + installed: true, + message: "OpenCode server rejected authentication. Check the server URL and password.", + }; + } + + if ( + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("networkerror") || + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("socket hang up") + ) { + return { + installed: true, + message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, + }; + } + + return { + installed: true, + message: detail ?? "Failed to connect to the configured OpenCode server.", + }; + } + + if (lower.includes("enoent") || lower.includes("notfound")) { + return { + installed: false, + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + }; + } + + if (lower.includes("quarantine")) { + return { + installed: true, + message: + "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", + }; + } + + if (lower.includes("invalid code signature") || lower.includes("corrupted")) { + return { + installed: true, + message: + "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", + }; + } + + return { + installed: true, + message: detail + ? `Failed to execute OpenCode CLI health check: ${detail}` + : "Failed to execute OpenCode CLI health check.", + }; +} + +function titleCaseSlug(value: string): string { + return value + .split(/[-_/]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function inferDefaultAgent(agents: ReadonlyArray): string | undefined { + return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; +} + +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const variantValues = Object.keys(input.model.variants ?? {}); + const defaultVariant = inferDefaultVariant(input.providerID, variantValues); + const variantOptions = variantValues.map((value) => + defaultVariant === value + ? { id: value, label: titleCaseSlug(value), isDefault: true as const } + : { id: value, label: titleCaseSlug(value) }, + ); + const primaryAgents = input.agents.filter( + (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), + ); + const defaultAgent = inferDefaultAgent(primaryAgents); + const agentOptions = primaryAgents.map((agent) => + defaultAgent === agent.name + ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } + : { id: agent.name, label: titleCaseSlug(agent.name) }, + ); + return createModelCapabilities({ + optionDescriptors: [ + ...(variantOptions.length > 0 + ? [ + { + id: "variant", + label: "Variant", + type: "select" as const, + options: variantOptions, + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + ] + : []), + ...(agentOptions.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: agentOptions, + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }, + ] + : []), + ], + }); +} + +function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + const name = nonEmptyTrimmed(model.name); + if (!name) { + continue; + } + + const subProvider = nonEmptyTrimmed(provider.name); + models.push({ + slug: `${provider.id}/${model.id}`, + name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export const makePendingOpenCodeProvider = ( + openCodeSettings: OpenCodeSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + [], + PROVIDER, + openCodeSettings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: + openCodeSettings.serverUrl.trim().length > 0 + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode provider status has not been checked in this session yet.", + }, + }); + }); + +export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( + openCodeSettings: OpenCodeSettings, + cwd: string, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const openCodeRuntime = yield* OpenCodeRuntime; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const customModels = openCodeSettings.customModels; + const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; + + const fallback = (cause: unknown, version: string | null = null) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + serverUrl: openCodeSettings.serverUrl, + }); + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: false, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + openCodeRuntime + .runOpenCodeCommand({ + binaryPath: openCodeSettings.binaryPath, + args: ["--version"], + environment, + }) + .pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + + if (!version) { + return fallback( + new Error( + `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + ), + null, + ); + } + if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: openCodeSettings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }, + }); + } + } + + const inventoryExit = yield* Effect.exit( + Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + environment, + }) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ); + return yield* openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(isExternalServer && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }), + ) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ); + }), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version); + } + + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value), + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const connectedCount = inventoryExit.value.providerList.connected.length; + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fea..7fb545b2bed 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -1,18 +1,34 @@ -import type { ProviderKind } from "@t3tools/contracts"; +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Layer, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; -import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; -import { ProviderUnsupportedError } from "../Errors.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); + const fakeCodexAdapter: CodexAdapterShape = { - provider: "codex", + provider: CODEX_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), @@ -29,7 +45,41 @@ const fakeCodexAdapter: CodexAdapterShape = { }; const fakeClaudeAdapter: ClaudeAdapterShape = { - provider: "claudeAgent", + provider: CLAUDE_AGENT_DRIVER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { + provider: OPENCODE_DRIVER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + +const fakeCursorAdapter: CursorAdapterShape = { + provider: CURSOR_DRIVER, capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), @@ -45,38 +95,98 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; -const layer = it.layer( - Layer.mergeAll( - Layer.provide( - ProviderAdapterRegistryLive, - Layer.mergeAll( - Layer.succeed(CodexAdapter, fakeCodexAdapter), - Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), - ), - ), - NodeServices.layer, - ), +// ProviderAdapterRegistryLive is now a facade over ProviderInstanceRegistry — +// it walks `listInstances` once at boot and surfaces the default-instance +// adapter keyed by its driver kind. To test the facade we supply four fake +// instances whose `instanceId === defaultInstanceIdForDriver(driverKind)` so +// they pass the default-instance filter. +const makeFakeInstance = ( + driverKindString: "codex" | "claudeAgent" | "cursor" | "opencode", + adapter: ProviderInstance["adapter"], +): ProviderInstance => { + const driverKind = ProviderDriverKind.make(driverKindString); + return { + instanceId: defaultInstanceIdForDriver(driverKind), + driverKind, + continuationIdentity: { + driverKind, + continuationKey: `${driverKind}:instance:${defaultInstanceIdForDriver(driverKind)}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driverKind, + packageName: null, + }), + getSnapshot: Effect.succeed({} as unknown as ServerProvider), + refresh: Effect.succeed({} as unknown as ServerProvider), + streamChanges: Stream.empty, + }, + adapter, + textGeneration: {} as unknown as TextGenerationShape, + }; +}; + +const fakeInstances: ReadonlyArray = [ + makeFakeInstance("codex", fakeCodexAdapter), + makeFakeInstance("claudeAgent", fakeClaudeAdapter), + makeFakeInstance("opencode", fakeOpenCodeAdapter), + makeFakeInstance("cursor", fakeCursorAdapter), +]; + +const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(fakeInstances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(fakeInstances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + // Tests never drive changes through this fake; acquire a throwaway + // subscription on an unused PubSub so the shape is satisfied. + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub)), +}); + +const layer = Layer.mergeAll( + Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer), + NodeServices.layer, ); -layer("ProviderAdapterRegistryLive", (it) => { - it.effect("resolves a registered provider adapter", () => +it.layer(layer)("ProviderAdapterRegistryLive", (it) => { + it("resolves adapters and routing metadata from provider instances", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; - const codex = yield* registry.getByProvider("codex"); - const claude = yield* registry.getByProvider("claudeAgent"); - assert.equal(codex, fakeCodexAdapter); - assert.equal(claude, fakeClaudeAdapter); + const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER); - const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); - }), - ); + const adapter = yield* registry.getByInstance(claudeInstanceId); + assert.strictEqual(adapter, fakeClaudeAdapter); - it.effect("fails with ProviderUnsupportedError for unknown providers", () => - Effect.gen(function* () { - const registry = yield* ProviderAdapterRegistry; - const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); - assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); - }), - ); + const info = yield* registry.getInstanceInfo(claudeInstanceId); + assert.deepStrictEqual(info, { + instanceId: claudeInstanceId, + driverKind: CLAUDE_AGENT_DRIVER, + displayName: undefined, + accentColor: undefined, + enabled: true, + continuationIdentity: { + driverKind: CLAUDE_AGENT_DRIVER, + continuationKey: "claudeAgent:instance:claudeAgent", + }, + }); + + const instances = yield* registry.listInstances(); + assert.deepStrictEqual(instances, [ + defaultInstanceIdForDriver(CODEX_DRIVER), + claudeInstanceId, + defaultInstanceIdForDriver(OPENCODE_DRIVER), + defaultInstanceIdForDriver(CURSOR_DRIVER), + ]); + + const providers = yield* registry.listProviders(); + assert.deepStrictEqual(providers, [ + CODEX_DRIVER, + CLAUDE_AGENT_DRIVER, + OPENCODE_DRIVER, + CURSOR_DRIVER, + ]); + })); }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b6c987c64c3..b492399b10b 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -1,50 +1,102 @@ /** - * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. + * ProviderAdapterRegistryLive — facade over `ProviderInstanceRegistry`. * - * Binds provider kinds (codex/claudeAgent/...) to concrete adapter services. - * This layer only performs adapter lookup; it does not route session-scoped - * calls or own provider lifecycle workflows. + * `ProviderAdapterRegistry` historically mapped one `ProviderDriverKind` to one + * adapter via the four `AdapterLive` singleton Layers. The per-instance + * refactor moved adapter construction inside each `ProviderDriver.create()`: + * adapters are now bundled on the `ProviderInstance` that the + * `ProviderInstanceRegistry` owns. + * + * This facade fulfills the `ProviderAdapterRegistryShape` contract by doing + * dynamic look-ups against `ProviderInstanceRegistry` on every call. That + * means settings-driven hot-reload shows up here automatically — adding a + * new instance via settings makes `getByInstance` resolve immediately + * without rebuilding the facade. * * @module ProviderAdapterRegistryLive */ -import { Effect, Layer } from "effect"; +import { + defaultInstanceIdForDriver, + ProviderInstanceId, + type ProviderDriverKind, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { ProviderUnsupportedError } from "../Errors.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; - -export interface ProviderAdapterRegistryLiveOptions { - readonly adapters?: ReadonlyArray>; -} - -const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* ( - options?: ProviderAdapterRegistryLiveOptions, -) { - const adapters = - options?.adapters !== undefined - ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; - const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); - - const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { - const adapter = byProvider.get(provider); - if (!adapter) { - return Effect.fail(new ProviderUnsupportedError({ provider })); - } - return Effect.succeed(adapter); - }; + +const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { + const registry = yield* ProviderInstanceRegistry; + + const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance === undefined + ? Effect.fail( + new ProviderUnsupportedError({ + provider: instanceId, + }), + ) + : Effect.succeed(instance.adapter), + ), + ); + + const getInstanceInfo: ProviderAdapterRegistryShape["getInstanceInfo"] = (instanceId) => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance === undefined + ? Effect.fail( + new ProviderUnsupportedError({ + provider: instanceId, + }), + ) + : Effect.succeed({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + displayName: instance.displayName, + accentColor: instance.accentColor, + enabled: instance.enabled, + continuationIdentity: instance.continuationIdentity, + }), + ), + ); + + const listInstances: ProviderAdapterRegistryShape["listInstances"] = () => + registry.listInstances.pipe( + Effect.map((instances) => instances.map((instance) => instance.instanceId)), + ); const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => - Effect.sync(() => Array.from(byProvider.keys())); + registry.listInstances.pipe( + Effect.map((instances) => { + const kinds = new Set(); + for (const instance of instances) { + const defaultId = defaultInstanceIdForDriver(instance.driverKind); + if (instance.instanceId === defaultId) { + // Only the default-instance rows show up through the legacy + // shim — custom instances like `codex_personal` have no + // `ProviderDriverKind` equivalent. + kinds.add(instance.driverKind); + } + } + return Array.from(kinds); + }), + ); return { - getByProvider, + getByInstance, + getInstanceInfo, + listInstances, listProviders, + // Proxy directly — the facade has no state of its own; the instance + // registry already coalesces adds/removes/rebuilds into one emission. + streamChanges: registry.streamChanges, + subscribeChanges: registry.subscribeChanges, } satisfies ProviderAdapterRegistryShape; }); @@ -52,3 +104,14 @@ export const ProviderAdapterRegistryLive = Layer.effect( ProviderAdapterRegistry, makeProviderAdapterRegistry(), ); + +// Exposed for tests that want to build a facade over a pre-assembled +// `ProviderInstanceRegistry` without pulling in the whole boot graph. +export { makeProviderAdapterRegistry }; + +// Re-export for consumers that need the accessor shape. The service tag +// itself lives in `Services/ProviderAdapterRegistry.ts`. +export { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +// Re-export for consumers (including tests) that construct a +// `ProviderInstanceId` before calling `getByInstance`. +export { ProviderInstanceId }; diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts new file mode 100644 index 00000000000..14e9a1075d5 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderEventLoggers.ts @@ -0,0 +1,85 @@ +/** + * ProviderEventLoggers — single observability service that owns the two + * shared NDJSON streams the provider runtime writes: + * + * - `native` — provider-protocol events as the SDK emits them, written + * from inside each `Adapter` factory. + * - `canonical` — runtime events after `ProviderService` has normalized + * them onto `ProviderRuntimeEvent`. + * + * Why a service tag and not constructor options? + * + * - Adapters are now constructed *inside* drivers (`Driver.create()`), + * 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. + * - 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`. + * + * @module provider/Layers/ProviderEventLoggers + */ +import * as Context from "effect/Context"; +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; +} + +/** + * 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. + */ +export class ProviderEventLoggers extends Context.Service< + ProviderEventLoggers, + ProviderEventLoggersShape +>()("t3/provider/ProviderEventLoggers") {} + +/** + * Constant value used by tests / boot layers that want to opt out of native + * + 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 = { + 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. + */ +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; + }), +); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts new file mode 100644 index 00000000000..4e43e04cb7c --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -0,0 +1,178 @@ +/** + * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` + * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. + * + * The server still reads two shapes: + * + * 1. `settings.providerInstances` — the new driver-agnostic map the + * registry expects. Keyed by `ProviderInstanceId`, values are + * `ProviderInstanceConfig` envelopes. + * 2. `settings.providers.` — the legacy single-instance-per-driver + * fields (`providers.codex`, `providers.claudeAgent`, …). These are + * the source of truth for every deployment that hasn't been migrated + * yet to an explicit `providerInstances` entry. + * + * This module bridges (2) into (1) and wires the resulting map into a + * mutable registry. For every built-in driver whose id is not already + * present in `providerInstances` (keyed on + * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a + * routing slug), we synthesize an envelope from the legacy field. The + * registry decodes both flavours through the same `configSchema` and ends + * up with one uniform `ProviderInstance` per entry. + * + * Explicit `providerInstances` entries always win — users can already + * override the legacy `providers.` blob by authoring a + * `providerInstances.codex` entry with a matching driver, and we don't + * want the synthesized envelope to silently stomp their config. + * + * Hot-reload + * ---------- + * On layer build we: + * 1. Read the current `ServerSettings` once and use it to seed the + * registry's initial state via `ProviderInstanceRegistryMutableLayer`. + * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that + * subscribes to `ServerSettingsService.streamChanges` and calls + * `ProviderInstanceRegistryMutator.reconcile` on every emission. + * + * Failures inside the watcher are logged and swallowed so a single bad + * settings emission cannot kill the registry. Unknown drivers and invalid + * configs already round-trip through the registry's own "unavailable" + * shadow bucket. + * + * @module provider/Layers/ProviderInstanceRegistryHydration + */ +import { + defaultInstanceIdForDriver, + type ProviderInstanceConfig, + type ProviderInstanceConfigMap, + ServerSettings, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderInstanceRegistryMutator } from "../Services/ProviderInstanceRegistryMutator.ts"; +import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistryLive.ts"; + +/** + * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. + * + * Strategy: + * 1. Copy all explicit `settings.providerInstances` entries verbatim. + * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key + * is *not* already in the explicit map, synthesize an entry from the + * matching legacy `settings.providers.` blob. + * + * The returned map is the input the registry consumes; pure & exported + * separately so the hydration logic can be exercised by unit tests + * without layering. + */ +export const deriveProviderInstanceConfigMap = ( + settings: ServerSettings, +): ProviderInstanceConfigMap => { + const merged: Record = { ...settings.providerInstances }; + + for (const driver of BUILT_IN_DRIVERS) { + const instanceId = defaultInstanceIdForDriver(driver.driverKind); + if (instanceId in merged) { + // Explicit `providerInstances` entry for this slot — user-authored + // config always wins over the legacy mirror. + continue; + } + + // Only built-in drivers have a legacy mirror; the registry's + // `providers` struct is keyed on the same literal slug as + // `driverKind`. Access is dynamic (the driver kind is a branded string), + // but it's constrained to `keyof settings.providers` by the union of + // built-in driver kinds. + const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; + const legacyConfig = settings.providers[legacyKey]; + if (legacyConfig === undefined) { + continue; + } + + merged[instanceId] = { + driver: driver.driverKind, + config: legacyConfig, + }; + } + + return merged as ProviderInstanceConfigMap; +}; + +/** + * Layer that consumes `ProviderInstanceRegistryMutator` and forks a + * settings-watcher fiber. The fiber's lifetime is tied to the enclosing + * layer scope (process lifetime in production), so it is interrupted on + * shutdown without leaking. + * + * Errors inside the watcher are logged and swallowed — the registry's own + * "unavailable" bucket already absorbs unknown drivers and invalid + * configs, so the only way the watcher could fail is a settings stream + * tear-down, which logs and exits cleanly. + */ +const SettingsWatcherLive: Layer.Layer< + never, + never, + ProviderInstanceRegistryMutator | ServerSettingsService +> = Layer.effectDiscard( + Effect.gen(function* () { + const mutator = yield* ProviderInstanceRegistryMutator; + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.streamChanges.pipe( + Stream.runForEach((next) => + mutator + .reconcile(deriveProviderInstanceConfigMap(next)) + .pipe( + Effect.catchCause((cause) => + Effect.logError("ProviderInstanceRegistry reconcile failed", cause), + ), + ), + ), + Effect.forkScoped, + ); + }), +); + +/** + * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in + * sync with subsequent `streamChanges` emissions. + * + * The Layer's two halves: + * - `ProviderInstanceRegistryMutableLayer` produces the registry + + * mutator from the initial config map. Its scope owns every + * per-instance child scope created during reconcile. + * - `SettingsWatcherLive` consumes the mutator and runs a daemon fiber + * in the same scope. + * + * Composing via `Layer.provideMerge` makes the watcher's deps available + * from the mutable layer while still surfacing the registry as an output. + * The mutator tag is technically also exposed; only this module imports + * it, so the visibility leak is harmless in practice. + */ +export const ProviderInstanceRegistryHydrationLive: Layer.Layer< + ProviderInstanceRegistry, + never, + BuiltInDriversEnv | ServerSettingsService +> = Layer.unwrap( + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( + Effect.orElseSucceed(() => undefined), + ); + const initialConfigMap = + initialSettings === undefined + ? ({} as ProviderInstanceConfigMap) + : deriveProviderInstanceConfigMap(initialSettings); + + const mutableLayer = ProviderInstanceRegistryMutableLayer({ + drivers: BUILT_IN_DRIVERS, + configMap: initialConfigMap, + }); + + return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); + }), +) as Layer.Layer; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts new file mode 100644 index 00000000000..86f99c97326 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -0,0 +1,368 @@ +/** + * Multi-instance validation slices for `ProviderInstanceRegistryLive`. + * + * Two axes of the driver/registry refactor are exercised here: + * + * 1. **Same driver, many instances** — the "multi-instance codex slice" + * describe block below configures two independent `codex` instances and + * asserts each gets its own closures and identity. This is the + * multi-codex capability the refactor exists to unlock. + * + * 2. **Many drivers, one registry** — the "all drivers slice" describe + * block below configures one instance of every shipped driver + * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single + * `ProviderInstanceConfigMap` and asserts the registry boots them all + * without cross-contamination. This proves the driver SPI is uniform + * across every provider — any driver plugs into the registry through + * the same `ProviderDriver` value contract. + * + * Every instance in these tests is configured with `enabled: false` so the + * provider-status checks short-circuit to pending/disabled snapshots + * without trying to spawn real `codex` / `claude` / `agent` / `opencode` + * binaries. That keeps the assertions focused on registry routing + * behaviour rather than the runtime details of each provider. + */ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type ClaudeSettings, + type CodexSettings, + type CursorSettings, + type OpenCodeSettings, + ProviderDriverKind, + type ProviderInstanceConfigMap, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../../config.ts"; +import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; +import { CodexDriver } from "../Drivers/CodexDriver.ts"; +import { CursorDriver } from "../Drivers/CursorDriver.ts"; +import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; + +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), + ), +); + +const makeCodexConfig = (overrides: Partial): CodexSettings => ({ + enabled: false, + binaryPath: "codex", + homePath: "", + shadowHomePath: "", + customModels: [], + ...overrides, +}); + +const makeClaudeConfig = (overrides: Partial): ClaudeSettings => ({ + enabled: false, + binaryPath: "claude", + homePath: "", + customModels: [], + launchArgs: "", + ...overrides, +}); + +const makeCursorConfig = (overrides: Partial): CursorSettings => ({ + enabled: false, + binaryPath: "agent", + apiEndpoint: "", + customModels: [], + ...overrides, +}); + +const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ + enabled: false, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + ...overrides, +}); + +describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { + // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch + // directory. `Layer.merge` just unions requirements, so we have to push + // `NodeServices.layer` through `Layer.provideMerge` to satisfy that + // dependency while still surfacing NodeServices to the test body (the + // codex driver's `create` yields `ChildProcessSpawner` directly). + const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "provider-instance-registry-test", + }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => + Effect.gen(function* () { + const personalId = ProviderInstanceId.make("codex_personal"); + const workId = ProviderInstanceId.make("codex_work"); + const codexDriverKind = ProviderDriverKind.make("codex"); + + const configMap: ProviderInstanceConfigMap = { + [personalId]: { + driver: codexDriverKind, + displayName: "Codex (personal)", + enabled: false, + config: makeCodexConfig({ + binaryPath: "/opt/codex-personal/bin/codex", + homePath: "/home/julius/.codex_personal", + customModels: ["personal-preview"], + }), + }, + [workId]: { + driver: codexDriverKind, + displayName: "Codex (work)", + enabled: false, + config: makeCodexConfig({ + binaryPath: "/opt/codex-work/bin/codex", + homePath: "/home/julius/.codex", + customModels: ["work-preview"], + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap, + }); + + const instances = yield* registry.listInstances; + expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( + [personalId, workId].toSorted(), + ); + expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); + expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( + ["Codex (personal)", "Codex (work)"].toSorted(), + ); + + // Each instance must be retrievable by id and carry its *own* closures. + const personal = yield* registry.getInstance(personalId); + const work = yield* registry.getInstance(workId); + expect(personal).toBeDefined(); + expect(work).toBeDefined(); + expect(personal!.adapter).not.toBe(work!.adapter); + expect(personal!.textGeneration).not.toBe(work!.textGeneration); + expect(personal!.snapshot).not.toBe(work!.snapshot); + + // Snapshots identify themselves by instanceId + driver — this is + // what makes per-instance routing distinguishable downstream. + const personalSnapshot = yield* personal!.snapshot.getSnapshot; + expect(personalSnapshot.instanceId).toBe(personalId); + expect(personalSnapshot.driver).toBe(codexDriverKind); + expect(personalSnapshot.enabled).toBe(false); + expect(personalSnapshot.continuation?.groupKey).toBe( + "codex:home:/home/julius/.codex_personal", + ); + + const workSnapshot = yield* work!.snapshot.getSnapshot; + expect(workSnapshot.instanceId).toBe(workId); + expect(workSnapshot.driver).toBe(codexDriverKind); + expect(workSnapshot.enabled).toBe(false); + expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + + // Nothing goes to the unavailable bucket — both drivers are registered. + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.live( + "shadows instances whose driver is not registered in this build without failing boot", + () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex_main"); + const ghostId = ProviderInstanceId.make("ghost_main"); + + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: makeCodexConfig({}), + }, + [ghostId]: { + driver: ProviderDriverKind.make("ghostDriver"), + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload", preserved: true }, + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap, + }); + + const instances = yield* registry.listInstances; + expect(instances).toHaveLength(1); + expect(instances[0]!.instanceId).toBe(codexId); + + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toHaveLength(1); + const ghost = unavailable[0]!; + expect(ghost.instanceId).toBe(ghostId); + expect(ghost.driver).toBe("ghostDriver"); + expect(ghost.availability).toBe("unavailable"); + expect(ghost.unavailableReason).toMatch(/ghostDriver/); + }).pipe(Effect.provide(testLayer)), + ); +}); + +describe("ProviderInstanceRegistryLive — all drivers slice", () => { + // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem + + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` + // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. + // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via + // `Layer.provide`, so the only external requirement it still exposes is + // `ChildProcessSpawner` — resolved here by piping it through + // `provideMerge(NodeServices.layer)`. + // + // The nested `provideMerge`s read bottom-up: `NodeServices.layer` + // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs + // surfaced; that merged layer then provides `ServerConfig.layerTest`'s + // `FileSystem` dep while keeping everything else surfaced to the test. + const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "provider-instance-registry-all-drivers-test", + }).pipe( + Layer.provideMerge(infraLayer), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + it.live("boots one instance of every shipped driver from a single config map", () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex_default"); + const claudeId = ProviderInstanceId.make("claude_default"); + const cursorId = ProviderInstanceId.make("cursor_default"); + const openCodeId = ProviderInstanceId.make("opencode_default"); + + const codexDriverKind = ProviderDriverKind.make("codex"); + const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); + const cursorDriverKind = ProviderDriverKind.make("cursor"); + const openCodeDriverKind = ProviderDriverKind.make("opencode"); + + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: codexDriverKind, + displayName: "Codex", + enabled: false, + config: makeCodexConfig({ homePath: "/home/julius/.codex" }), + }, + [claudeId]: { + driver: claudeDriverKind, + displayName: "Claude", + enabled: false, + config: makeClaudeConfig({ + homePath: "/home/julius/.claude-work", + launchArgs: "--verbose", + }), + }, + [cursorId]: { + driver: cursorDriverKind, + displayName: "Cursor", + enabled: false, + config: makeCursorConfig({}), + }, + [openCodeId]: { + driver: openCodeDriverKind, + displayName: "OpenCode", + enabled: false, + config: makeOpenCodeConfig({}), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver], + configMap, + }); + + // Every configured instance must materialize — none downgraded to a + // shadow snapshot, because every driver in the map is registered. + const unavailable = yield* registry.listUnavailable; + expect(unavailable).toEqual([]); + + const instances = yield* registry.listInstances; + expect(instances).toHaveLength(4); + expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( + [codexId, claudeId, cursorId, openCodeId].toSorted(), + ); + + // Instance lookup by id resolves each instance to its own bundle — + // this is how rest-of-server routes turn/session calls in the new + // model. Each driver's bundle carries its advertised `driverKind`. + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + const cursor = yield* registry.getInstance(cursorId); + const openCode = yield* registry.getInstance(openCodeId); + expect(codex?.driverKind).toBe(codexDriverKind); + expect(claude?.driverKind).toBe(claudeDriverKind); + expect(cursor?.driverKind).toBe(cursorDriverKind); + expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(codex?.displayName).toBe("Codex"); + expect(claude?.displayName).toBe("Claude"); + expect(cursor?.displayName).toBe("Cursor"); + expect(openCode?.displayName).toBe("OpenCode"); + + // Every instance owns its own set of closures — no sharing across + // drivers. `adapter` / `textGeneration` / `snapshot` are all + // distinct references even when two instances happen to share a + // trait (e.g. Cursor + others all use a stub-or-real + // `textGeneration`; they must still be different object values). + const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter]; + expect(new Set(adapters).size).toBe(adapters.length); + const textGenerations = [ + codex!.textGeneration, + claude!.textGeneration, + cursor!.textGeneration, + openCode!.textGeneration, + ]; + expect(new Set(textGenerations).size).toBe(textGenerations.length); + const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot]; + expect(new Set(snapshots).size).toBe(snapshots.length); + + // Snapshots identify themselves by `instanceId` + `driver` so + // downstream aggregation in `ProviderRegistry` can tell instances + // apart even when two share a driver. With `enabled: false`, the + // check short-circuits and we get a disabled/pending snapshot back + // — that's enough signal to validate the stamping wrapper without + // spawning real binaries. + const codexSnapshot = yield* codex!.snapshot.getSnapshot; + expect(codexSnapshot.instanceId).toBe(codexId); + expect(codexSnapshot.driver).toBe(codexDriverKind); + expect(codexSnapshot.enabled).toBe(false); + expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + + const claudeSnapshot = yield* claude!.snapshot.getSnapshot; + expect(claudeSnapshot.instanceId).toBe(claudeId); + expect(claudeSnapshot.driver).toBe(claudeDriverKind); + expect(claudeSnapshot.enabled).toBe(false); + expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); + + const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; + expect(cursorSnapshot.instanceId).toBe(cursorId); + expect(cursorSnapshot.driver).toBe(cursorDriverKind); + expect(cursorSnapshot.enabled).toBe(false); + expect(cursorSnapshot.continuation?.groupKey).toBe( + `${cursorDriverKind}:instance:${cursorId}`, + ); + + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; + expect(openCodeSnapshot.instanceId).toBe(openCodeId); + expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); + expect(openCodeSnapshot.enabled).toBe(false); + expect(openCodeSnapshot.continuation?.groupKey).toBe( + `${openCodeDriverKind}:instance:${openCodeId}`, + ); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts new file mode 100644 index 00000000000..b51dc67793e --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -0,0 +1,443 @@ +/** + * ProviderInstanceRegistryLive — runtime implementation of + * `ProviderInstanceRegistry` plus its sibling mutator. + * + * Materializes every entry in a `ProviderInstanceConfigMap`: + * + * - When the entry's `driver` matches a registered driver, the registry + * decodes the opaque `config` envelope through `driver.configSchema` + * and calls `driver.create()` inside a fresh child scope. The + * resulting `ProviderInstance` is stored keyed by instance id, + * alongside its scope so the entry can be torn down independently. + * - When the entry's `driver` is unknown to this build (fork, rollback, + * in-flight PR branch), the registry emits an `"unavailable"` shadow + * `ServerProvider` snapshot instead of failing. This is what makes + * downgrades and fork-hopping safe per the + * `forward/backward compatibility invariant` in + * `packages/contracts/src/providerInstance.ts`. + * - When the entry's config fails schema decode, the registry logs and + * emits a shadow snapshot with the schema detail — same bucket as an + * unknown driver. + * + * Unlike the pre-Slice-D layer, the registry now holds mutable state + * (`Ref`s + `PubSub`) and exposes an internal mutator + * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a + * fresh config map against the live state, tearing down removed instances + * and building new ones without disturbing unaffected instances. + * + * Every live instance runs inside its own child `Scope`. The registry's + * own scope owns all child scopes via finalizers, so closing the registry + * tears every instance down in reverse order; closing a single instance + * (via `reconcile` removing it) leaves the rest untouched. + * + * @module provider/Layers/ProviderInstanceRegistryLive + */ +import { + defaultInstanceIdForDriver, + ProviderInstanceId, + type ProviderInstanceConfig, + type ProviderInstanceConfigMap, + type ProviderDriverKind, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +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 { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts"; +import { + ProviderInstanceRegistry, + type ProviderInstanceRegistryShape, +} from "../Services/ProviderInstanceRegistry.ts"; +import { + ProviderInstanceRegistryMutator, + type ProviderInstanceRegistryMutatorShape, +} from "../Services/ProviderInstanceRegistryMutator.ts"; +import type { AnyProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; + +/** + * Live registry entry: the materialized `ProviderInstance` + the fresh + * child scope its `create` effect ran in + the original `entry` envelope + * so `reconcile` can cheaply detect "no-op" updates. + */ +interface LiveEntry { + readonly instance: ProviderInstance; + readonly scope: Scope.Closeable; + readonly entry: ProviderInstanceConfig; +} + +/** + * Internal state shared between the public registry service and the + * mutator service. Both services are thin shells around these refs. + */ +interface RegistryState { + readonly entries: Ref.Ref>; + readonly unavailable: Ref.Ref>; + readonly changes: PubSub.PubSub; +} + +/** + * Structural equality on `ProviderInstanceConfig` envelopes. Used by + * `reconcile` to skip rebuilds when settings arrive unchanged. Config + * payloads are opaque `unknown` at the envelope layer; `Equal.equals` + * falls back to structural equality for plain records, which matches how + * the schema decode output is constructed. + */ +const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => + Equal.equals(a, b); + +const decodedConfigEnabled = (config: unknown): boolean | undefined => { + if (!config || typeof config !== "object" || globalThis.Array.isArray(config)) { + return undefined; + } + const enabled = (config as { readonly enabled?: unknown }).enabled; + return typeof enabled === "boolean" ? enabled : undefined; +}; + +/** + * Build one live entry from a raw config envelope. Returns either a + * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and + * undefined entry — callers dispatch to the appropriate Ref bucket. + */ +const buildEntry = (input: { + readonly driversById: ReadonlyMap>; + readonly parentScope: Scope.Scope; + readonly instanceId: ProviderInstanceId; + readonly rawInstanceId: string; + readonly entry: ProviderInstanceConfig; +}): Effect.Effect< + | { readonly kind: "live"; readonly live: LiveEntry } + | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, + never, + R +> => + Effect.gen(function* () { + const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + const driver = driversById.get(entry.driver); + if (!driver) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' is not registered in this build.`, + }), + }; + } + + const decoder = Schema.decodeUnknownEffect(driver.configSchema); + const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); + if (decodeResult._tag === "Failure") { + const issue = decodeResult.failure; + const detail = issue.message ?? String(issue); + yield* Effect.logError("Failed to decode provider instance config", { + instanceId: rawInstanceId, + driver: entry.driver, + detail, + }); + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, + }), + }; + } + + const typedConfig = decodeResult.success; + const childScope = yield* Scope.make(); + // Attach the child scope to the registry's parent scope: if the + // registry scope closes, each surviving instance's child scope is + // closed through this finalizer. `reconcile` manually closes the + // child scope on remove/replace; subsequent close via the parent's + // finalizer is a no-op because `Scope.close` is idempotent. + yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); + + const createResult = yield* driver + .create({ + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + environment: entry.environment ?? [], + enabled: entry.enabled ?? decodedConfigEnabled(typedConfig) ?? true, + config: typedConfig, + }) + .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); + if (createResult._tag === "Failure") { + yield* Effect.logError("Failed to create provider instance", { + instanceId: rawInstanceId, + driver: entry.driver, + detail: createResult.failure.detail, + }); + yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, + }), + }; + } + + return { + kind: "live" as const, + live: { + instance: createResult.success, + scope: childScope, + entry, + }, + }; + }); + +/** + * Reconcile-only implementation of the mutator. Exposed to the hydration + * layer; never called directly by the rest of the server. + */ +const makeReconcile = (input: { + readonly state: RegistryState; + readonly driversById: ReadonlyMap>; + readonly parentScope: Scope.Scope; +}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect) => { + const { state, driversById, parentScope } = input; + return (configMap: ProviderInstanceConfigMap) => + Effect.gen(function* () { + const previousEntries = yield* Ref.get(state.entries); + const previousUnavailable = yield* Ref.get(state.unavailable); + const nextRaw = Object.entries(configMap); + const nextKeys = new Set( + nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), + ); + + // 1. Close scopes for instances that disappeared or whose config + // changed. Do this BEFORE creating replacements so ids map 1-to-1 + // to live scopes at all times. + const removedIds: Array = []; + const replacedIds = new Set(); + for (const [instanceId, live] of previousEntries) { + if (!nextKeys.has(instanceId)) { + removedIds.push(instanceId); + continue; + } + const nextEntry = configMap[instanceId]; + if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { + replacedIds.add(instanceId); + } + } + for (const id of [...removedIds, ...replacedIds]) { + const live = previousEntries.get(id); + if (live) { + yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); + } + } + + // 2. Build additions and replacements. Walk `nextRaw` so the final + // entry order follows settings-author order. + const builtEntries = new Map(); + const builtUnavailable = new Map(); + let orderChanged = false; + const previousOrder = [...previousEntries.keys()]; + const nextOrder: Array = []; + + for (const [rawInstanceId, entry] of nextRaw) { + const instanceId = ProviderInstanceId.make(rawInstanceId); + nextOrder.push(instanceId); + + const existing = previousEntries.get(instanceId); + if (existing !== undefined && !replacedIds.has(instanceId)) { + // No-op update: keep the existing live entry and scope. + builtEntries.set(instanceId, existing); + continue; + } + + const result = yield* buildEntry({ + driversById, + parentScope, + instanceId, + rawInstanceId, + entry, + }); + if (result.kind === "live") { + builtEntries.set(instanceId, result.live); + } else { + builtUnavailable.set(instanceId, result.snapshot); + } + } + + if (previousOrder.length === nextOrder.length) { + for (let i = 0; i < previousOrder.length; i++) { + if (previousOrder[i] !== nextOrder[i]) { + orderChanged = true; + break; + } + } + } else { + orderChanged = true; + } + + const entriesChanged = + orderChanged || + removedIds.length > 0 || + replacedIds.size > 0 || + builtEntries.size !== previousEntries.size; + const unavailableChanged = + builtUnavailable.size !== previousUnavailable.size || + [...builtUnavailable].some(([id, snapshot]) => { + const prev = previousUnavailable.get(id); + return prev === undefined || !Equal.equals(prev, snapshot); + }) || + [...previousUnavailable].some(([id]) => !builtUnavailable.has(id)); + + yield* Ref.set(state.entries, builtEntries); + yield* Ref.set(state.unavailable, builtUnavailable); + + if (entriesChanged || unavailableChanged) { + yield* PubSub.publish(state.changes, undefined); + } + }); +}; + +/** + * Build the registry's runtime state from a concrete configMap. Returns a + * record containing: + * + * - `registry`: the read-only `ProviderInstanceRegistryShape` to expose + * under `ProviderInstanceRegistry`. + * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose + * under `ProviderInstanceRegistryMutator`. + * - `reconcile`: the raw reconcile function, provided for convenience so + * boot-time layers can hydrate an initial map before publishing the + * services. + * + * The scope that this effect runs in owns every per-instance child scope + * created during `reconcile`. Closing that scope closes every live + * instance. + */ +export const makeProviderInstanceRegistry = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Effect.Effect< + { + readonly registry: ProviderInstanceRegistryShape; + readonly mutator: ProviderInstanceRegistryMutatorShape; + }, + never, + R | Scope.Scope +> => + Effect.gen(function* () { + const driversById = new Map>( + input.drivers.map((driver) => [driver.driverKind, driver]), + ); + + // Capture the enclosing scope so per-instance child scopes can be + // attached to it at `reconcile` time. Without this, `reconcile` + // called later (e.g. from the hydration layer) would attach child + // scopes to the *caller's* scope instead of the registry's. + const parentScope = yield* Scope.Scope; + + // Capture the driver R context at construction time so `reconcile` + // can be invoked later without re-providing driver dependencies. + // The service tag's declared `reconcile: Effect` hides R from + // consumers — we materialize that here. + const driverContext = yield* Effect.context(); + + const entries = yield* Ref.make>(new Map()); + const unavailable = yield* Ref.make>(new Map()); + const changes = yield* PubSub.unbounded(); + yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); + + const state: RegistryState = { entries, unavailable, changes }; + const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const reconcile: ProviderInstanceRegistryMutatorShape["reconcile"] = (configMap) => + reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); + + // Hydrate the initial configMap synchronously so callers can read + // `listInstances` immediately after this effect completes. + yield* reconcile(input.configMap); + + const registry: ProviderInstanceRegistryShape = { + getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), + listInstances: Ref.get(entries).pipe( + Effect.map( + (map) => + Array.from(map.values(), (live) => live.instance) as ReadonlyArray, + ), + ), + listUnavailable: Ref.get(unavailable).pipe( + Effect.map((map) => Array.from(map.values()) as ReadonlyArray), + ), + // Getters: each read constructs a fresh Stream / Effect descriptor + // so multiple consumers don't share a single already-started + // Channel or subscription. Matches the pattern `ProviderRegistry` + // uses for its own `streamChanges`. + get streamChanges() { + return Stream.fromPubSub(changes); + }, + // Synchronous subscribe — callers that need to consume changes + // from a forked fibre must acquire the subscription in their own + // fibre first (via `yield* registry.subscribeChanges`) and only + // then fork a consumer loop on `Stream.fromSubscription(...)` / + // `PubSub.take(...)`. See the shape docs for the race this avoids. + get subscribeChanges() { + return PubSub.subscribe(changes); + }, + }; + + const mutator: ProviderInstanceRegistryMutatorShape = { reconcile }; + + return { registry, mutator }; + }); + +/** + * Assemble a `ProviderInstanceRegistry` Layer bound to a fixed set of + * drivers and a pre-resolved `ProviderInstanceConfigMap`. Used by tests + * that want explicit control over the registry's source-of-truth without + * wiring up the settings watcher. + * + * Only exposes the public registry tag — hot-reload consumers should use + * `ProviderInstanceRegistryMutableLayer` (below) or the hydration layer. + */ +export const ProviderInstanceRegistryLayer = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Layer.Layer => + Layer.effect( + ProviderInstanceRegistry, + makeProviderInstanceRegistry(input).pipe(Effect.map((built) => built.registry)), + ) as Layer.Layer; + +/** + * Layer variant that also exposes the mutator tag. Consumed by + * `ProviderInstanceRegistryHydrationLive` to reconcile on settings + * changes. Tests that exercise the mutator directly can pair this Layer + * with a test-local `ServerSettingsService`. + */ +export const ProviderInstanceRegistryMutableLayer = (input: { + readonly drivers: ReadonlyArray>; + readonly configMap: ProviderInstanceConfigMap; +}): Layer.Layer => + Layer.effectContext( + makeProviderInstanceRegistry(input).pipe( + Effect.map(({ registry, mutator }) => + Context.make(ProviderInstanceRegistry, registry).pipe( + Context.add(ProviderInstanceRegistryMutator, mutator), + ), + ), + ), + ) as Layer.Layer; + +export { defaultInstanceIdForDriver }; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9c12048e45e..fb6eb3b443d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,43 +1,122 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; +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 PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as CodexErrors from "effect-codex-app-server/errors"; import { - Effect, - Exit, - FileSystem, - Layer, - Path, - PubSub, - Ref, - Schema, - Scope, - Sink, - Stream, -} from "effect"; -import { + ClaudeSettings, + CodexSettings, DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, ServerSettings, type ServerProvider, + type ServerProviderSlashCommand, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; import * as PlatformError from "effect/PlatformError"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { deepMerge } from "@t3tools/shared/Struct"; - +import { createModelCapabilities } from "@t3tools/shared/model"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; + +import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; +import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; +import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { - checkCodexProviderStatus, - hasCustomModelProvider, - parseAuthStatusFromOutput, - readCodexConfigModelProvider, -} from "./CodexProvider"; -import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; -import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; -import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; -import { ProviderRegistry } from "../Services/ProviderRegistry"; + haveProvidersChanged, + mergeProviderSnapshot, + mergeProviderSnapshots, + ProviderRegistryLive, + selectProvidersByKind, +} from "./ProviderRegistry.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +const decodeServerSettings = Schema.decodeSync(ServerSettings); +const encodeServerSettings = Schema.encodeSync(ServerSettings); +const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); + +const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); +const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); +const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ + enabled: false, +}); + +process.env.T3CODE_CURSOR_ENABLED = "1"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), + ), +); + +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string) { + return { + id, + label, + type: "boolean" as const, + }; +} + +type TestClaudeCapabilities = { + readonly email: string | undefined; + readonly subscriptionType: string | undefined; + readonly tokenSource: string | undefined; + readonly slashCommands: ReadonlyArray; +}; + +function claudeCapabilities(overrides: Partial = {}) { + return () => + Effect.succeed({ + email: undefined, + subscriptionType: undefined, + tokenSource: undefined, + slashCommands: [], + ...overrides, + }); +} + +const noClaudeCapabilities = () => + Effect.sync(() => undefined as TestClaudeCapabilities | undefined); + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -55,7 +134,11 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { } function mockSpawnerLayer( - handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, + handler: (args: ReadonlyArray) => { + stdout: string; + stderr: string; + code: number; + }, ) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -66,6 +149,33 @@ function mockSpawnerLayer( ); } +function recordingMockSpawnerLayer( + handler: (args: ReadonlyArray) => { + stdout: string; + stderr: string; + code: number; + }, +) { + const commands: Array<{ + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv | undefined; + }> = []; + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { + args: ReadonlyArray; + options?: { + readonly env?: NodeJS.ProcessEnv; + }; + }; + commands.push({ args: cmd.args, env: cmd.options?.env }); + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); + return { layer, commands }; +} + function mockCommandSpawnerLayer( handler: ( command: string, @@ -75,7 +185,10 @@ function mockCommandSpawnerLayer( return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { command: string; args: ReadonlyArray }; + const cmd = command as unknown as { + command: string; + args: ReadonlyArray; + }; return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); }), ); @@ -97,6 +210,67 @@ function failingSpawnerLayer(description: string) { ); } +function hangingScopedSpawnerLayer(killCalls: Ref.Ref) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Ref.update(killCalls, (current) => current + 1), + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.never, + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ), + ); +} + +const codexModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + { id: "low", label: "Low" }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + ], +}) satisfies NonNullable; + +function makeCodexProbeSnapshot( + input: Partial = {}, +): CodexAppServerProviderSnapshot { + return { + version: "1.0.0", + account: { + account: { + type: "chatgpt", + email: "test@example.com", + planType: "pro", + }, + requiresOpenaiAuth: false, + }, + models: [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ], + skills: [], + ...input, + }; +} + function makeMutableServerSettingsService( initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, ) { @@ -111,7 +285,8 @@ function makeMutableServerSettingsService( updateSettings: (patch) => Effect.gen(function* () { const current = yield* Ref.get(settingsRef); - const next = Schema.decodeSync(ServerSettings)(deepMerge(current, patch)); + const next = applyServerSettingsPatch(current, patch); + encodeServerSettings(next); yield* Ref.set(settingsRef, next); yield* PubSub.publish(changes, next); return next; @@ -123,126 +298,42 @@ function makeMutableServerSettingsService( }); } -/** - * Create a temporary CODEX_HOME scoped to the current Effect test. - * Cleanup is registered in the test scope rather than via Vitest hooks. - */ -function withTempCodexHome(configContent?: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); - - yield* Effect.acquireRelease( - Effect.sync(() => { - const originalCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = tmpDir; - return originalCodexHome; - }), - (originalCodexHome) => - Effect.sync(() => { - if (originalCodexHome !== undefined) { - process.env.CODEX_HOME = originalCodexHome; - } else { - delete process.env.CODEX_HOME; - } - }), - ); - - if (configContent !== undefined) { - yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); - } - - return { tmpDir } as const; - }); -} - -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( +it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))( "ProviderRegistry", (it) => { - // ── checkCodexProviderStatus tests ──────────────────────────────── - // - // These tests control CODEX_HOME to ensure the custom-provider detection - // in hasCustomModelProvider() does not interfere with the auth-probe - // path being tested. - describe("checkCodexProviderStatus", () => { - it.effect("returns ready when codex is installed and authenticated", () => - Effect.gen(function* () { - // Point CODEX_HOME at an empty tmp dir (no config.toml) so the - // default code path (OpenAI provider, auth probe runs) is exercised. - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns the codex plan type in auth and keeps spark for supported plans", () => + it.effect("uses the app-server account and model list for provider status", () => Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(() => - Effect.succeed({ - type: "chatgpt" as const, - planType: "pro" as const, - sparkEnabled: true, - }), + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ), ); - - assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "1.0.0"); assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "pro"); - assert.strictEqual(status.auth.label, "ChatGPT Pro Subscription"); - assert.deepStrictEqual( - status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), - true, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes probed codex skills in the provider snapshot", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus( - () => - Effect.succeed({ - type: "chatgpt" as const, - planType: "pro" as const, - sparkEnabled: true, - }), - () => - Effect.succeed([ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ]), - ); - + assert.strictEqual(status.auth.type, "chatgpt"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); + assert.strictEqual(status.auth.email, "test@example.com"); + assert.deepStrictEqual(status.models, [ + { + slug: "gpt-live-codex", + name: "GPT Live Codex", + isCustom: false, + capabilities: codexModelCapabilities, + }, + ]); assert.deepStrictEqual(status.skills, [ { name: "github:gh-fix-ci", @@ -252,173 +343,101 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( shortDescription: "Debug failing GitHub Actions checks", }, ]); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), + }), ); - it.effect("hides spark from codex models for unsupported chatgpt plans", () => + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(() => - Effect.succeed({ - type: "chatgpt" as const, - planType: "plus" as const, - sparkEnabled: false, - }), + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, + }, + }), + ), ); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "plus"); - assert.strictEqual(status.auth.label, "ChatGPT Plus Subscription"); - assert.deepStrictEqual( - status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), - false, + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), + }), ); - it.effect("hides spark from codex models for non-pro chatgpt subscriptions", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(() => - Effect.succeed({ - type: "chatgpt" as const, - planType: "team" as const, - sparkEnabled: false, - }), - ); + it.effect( + "returns ready with unknown auth when app-server does not require OpenAI auth", + () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), + ); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.auth.type, "team"); - assert.strictEqual(status.auth.label, "ChatGPT Team Subscription"); - assert.deepStrictEqual( - status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), - false, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "unknown"); + }), ); it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(() => - Effect.succeed({ - type: "apiKey" as const, - planType: null, - sparkEnabled: false, - }), + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "apiKey" }, + requiresOpenaiAuth: false, + }, + }), + ), ); - assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "apiKey"); assert.strictEqual(status.auth.label, "OpenAI API Key"); - assert.deepStrictEqual( - status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), - false, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), + }), ); - it.effect.skipIf(process.platform === "win32")( - "inherits PATH when launching the codex probe with a CODEX_HOME override", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-test-codex-bin-", - }); - const codexPath = path.join(binDir, "codex"); - yield* fileSystem.writeFileString( - codexPath, - [ - "#!/bin/sh", - 'if [ "$1" = "--version" ]; then', - ' echo "codex-cli 1.0.0"', - " exit 0", - "fi", - 'if [ "$1" = "login" ] && [ "$2" = "status" ]; then', - ' echo "Logged in using ChatGPT"', - " exit 0", - "fi", - 'echo "unexpected args: $*" >&2', - "exit 1", - "", - ].join("\n"), - ); - yield* fileSystem.chmod(codexPath, 0o755); - const customCodexHome = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-test-codex-home-", - }); - const previousPath = process.env.PATH; - process.env.PATH = binDir; - - try { - const serverSettingsLayer = ServerSettingsService.layerTest({ - providers: { - codex: { - homePath: customCodexHome, - }, + it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: { type: "amazonBedrock" }, + requiresOpenaiAuth: false, }, - }); + }), + ), + ); - const status = yield* checkCodexProviderStatus().pipe( - Effect.provide(serverSettingsLayer), - ); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - } finally { - process.env.PATH = previousPath; - } - }), + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "amazonBedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }), ); it.effect("returns unavailable when codex is missing", () => Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.provider, "codex"); + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.fail( + new CodexErrors.CodexAppServerSpawnError({ + command: "codex app-server", + cause: new Error("spawn codex ENOENT"), + }), + ), + ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); @@ -426,107 +445,29 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( status.message, "Codex CLI (`codex`) is not installed or not on PATH.", ); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + }), ); - it.effect("returns unavailable when codex is below the minimum supported version", () => + it.effect("closes the app-server probe scope when provider status times out", () => Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + const killCalls = yield* Ref.make(0); + const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provide(hangingScopedSpawnerLayer(killCalls)), + Effect.forkChild, ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("returns unauthenticated when auth probe reports login required", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); + yield* Effect.yieldNow; + yield* TestClock.adjust("11 seconds"); + yield* Effect.yieldNow; - it.effect("returns unauthenticated when login status output includes 'not logged in'", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.provider, "codex"); + const status = yield* Fiber.join(statusFiber); assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unauthenticated"); assert.strictEqual( status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", + "Timed out while checking Codex app-server provider status.", ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when login status command is unsupported", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI authentication status command is unavailable in this Codex version.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), + assert.strictEqual(yield* Ref.get(killCalls), 1); + }), ); }); @@ -534,7 +475,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it("treats equal provider snapshots as unchanged", () => { const providers = [ { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), status: "ready", enabled: true, installed: true, @@ -546,7 +488,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( skills: [], }, { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), status: "warning", enabled: true, installed: true, @@ -562,86 +505,819 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); - it.effect("reruns codex health when codex provider settings change", () => + it("preserves previously discovered provider models when a refresh returns none", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("fills missing capabilities from the previous provider snapshot", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [], + }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("persists merged provider snapshots for the providers that were refreshed", () => { + const previousProviders = [ + { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; + const refreshedCursor = { + ...previousProviders[0], + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); + const persistedProviders = selectProvidersByKind( + mergedProviders, + new Set([ProviderDriverKind.make("cursor")]), + ); + + assert.deepStrictEqual(persistedProviders, [ + { + ...refreshedCursor, + models: [...previousProviders[0].models], + }, + ]); + }); + + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { - const serverSettings = yield* makeMutableServerSettingsService(); + const cursorDriver = ProviderDriverKind.make("cursor"); + const cursorInstanceId = ProviderInstanceId.make("cursor"); + const initialProvider = { + instanceId: cursorInstanceId, + driver: cursorDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + ], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...initialProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: cursorInstanceId, + driverKind: cursorDriver, + continuationIdentity: { + driverKind: cursorDriver, + continuationKey: "cursor:instance:cursor", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: cursorDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(refreshedProvider), + streamChanges: Stream.fromPubSub(changes), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { - const joined = args.join(" "); - if (joined === "--version") { - if (command === "codex") { - return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - } - return { stdout: "", stderr: "spawn ENOENT", code: 1 }; - } - if (joined === "login status") { - return { stdout: "Logged in\n", stderr: "", code: 0 }; - } - throw new Error(`Unexpected args: ${joined}`); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-merged-persist-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const config = yield* ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: cursorInstanceId, + }); + + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + ...initialProvider.models, + ]); + yield* PubSub.publish(changes, refreshedProvider); + + let cachedProvider = yield* readProviderStatusCache(filePath); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider, { + ...refreshedProvider, + models: [...initialProvider.models], + }); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("returns the cached provider list when a manual refresh fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const cachedProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, }), + getSnapshot: Effect.succeed(cachedProvider), + refresh: Effect.die(new Error("simulated refresh failure")), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), ), - ); + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-refresh-failure-", + }), + ), + Layer.provideMerge(NodeServices.layer), ), ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; - const initial = yield* registry.getProviders; + assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); + assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ + cachedProvider, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("keeps consuming registry changes after one sync fails", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const claudeProvider = { + instanceId: claudeInstanceId, + driver: claudeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:01:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const makeInstance = (provider: ServerProvider): ProviderInstance => ({ + instanceId: provider.instanceId, + driverKind: provider.driver, + continuationIdentity: { + driverKind: provider.driver, + continuationKey: `${provider.driver}:instance:${provider.instanceId}`, + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: provider.driver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const codexInstance = makeInstance(codexProvider); + const claudeInstance = makeInstance(claudeProvider); + const changes = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([codexInstance]); + const failNextList = yield* Ref.make(false); + const wait = () => Effect.yieldNow; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === instanceId), + ), + ), + listInstances: Effect.gen(function* () { + const shouldFail = yield* Ref.get(failNextList); + if (shouldFail) { + yield* Ref.set(failNextList, false); + return yield* Effect.die(new Error("simulated registry list failure")); + } + return yield* Ref.get(instancesRef); + }), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: PubSub.subscribe(changes), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-sync-failure-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); + + yield* Ref.set(failNextList, true); + yield* PubSub.publish(changes, undefined); + + yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); + yield* PubSub.publish(changes, undefined); + + let providers = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && + !providers.some((provider) => provider.instanceId === claudeInstanceId); + attempt += 1 + ) { + yield* wait(); + providers = yield* registry.getProviders; + } + + assert.deepStrictEqual( + providers.map((provider) => provider.instanceId).toSorted(), + [codexInstanceId, claudeInstanceId].toSorted(), + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // This test intentionally avoids `mockCommandSpawnerLayer` so the real + // `probeCodexAppServerProvider` path runs — including the full + // `codex app-server` RPC handshake via `CodexClient.layerCommand`. + // We point `binaryPath` at a name that cannot exist on any machine so + // the real `ChildProcessSpawner` deterministically returns ENOENT; the + // probe wraps that as `CodexAppServerSpawnError` and + // `checkCodexProviderStatus` turns it into the user-visible "not + // installed" error snapshot. If the aggregator's `syncLiveSources` + // breaks — the `codex_personal`-never-probes bug we are guarding + // against — that snapshot never lands in `getProviders` and the + // assertions below fail. + it.effect("propagates real Codex probe failures to the aggregator at boot", () => + Effect.gen(function* () { + const missingBinary = `t3code_codex_missing_`; + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + // Disable every built-in probe that would otherwise spawn + // on the CI host. `enabled: false` short-circuits each + // driver's probe *before* it touches the spawner, so the + // test environment stays isolated from the dev + // machine's PATH. + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, + // `providerInstances` keys are branded `ProviderInstanceId`; + // the branded index signature rejects plain string literals + // at the TS level even though the runtime schema happily + // accepts + decodes them. Cast the patch to `unknown` so + // the `Schema.decodeSync` below does the real validation. + providerInstances: { + // Matches the shape the user had in `.t3/dev/settings.json` + // when the bug was reported: a custom enabled Codex instance + // pointing at a binary the server has to actually spawn. + codex_personal: { + driver: "codex", + displayName: "Codex Personal", + enabled: true, + config: { + binaryPath: missingBinary, + homePath: `/tmp/${missingBinary}_home`, + }, + }, + } as unknown as ContractServerSettings["providerInstances"], + }), + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + // 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 + // what exercises the same failure mode as a misconfigured + // production `binaryPath`. + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const codexPersonal = providers.find( + (provider) => provider.instanceId === "codex_personal", + ); + assert.notStrictEqual( + codexPersonal, + undefined, + `Expected the aggregator to know about codex_personal; instead saw: ${providers + .map((provider) => provider.instanceId) + .join(", ")}`, + ); + assert.strictEqual( + codexPersonal?.status, + "error", + "Real Codex probe against a missing binary should surface as 'error' in the aggregator", + ); + assert.strictEqual(codexPersonal?.installed, false); assert.strictEqual( - initial.find((status) => status.provider === "codex")?.status, - "ready", + codexPersonal?.message, + "Codex CLI (`codex`) is not installed or not on PATH.", ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + // Guards the second half of the reported bug: changing + // `providers.codex.binaryPath` in settings must tear down the live + // instance and rebuild it so a fresh probe runs with the new binary. + // This test drives the real settings stream → registry reconcile → + // aggregator sync pipeline and asserts that `getProviders` reflects + // the new probe's outcome. If `syncLiveSources` stops awaiting the + // rebuilt instance's refresh (previous bug mode), the aggregator + // keeps the old snapshot and this test fails. + // + it.effect("re-probes when settings change the codex binaryPath", () => + Effect.gen(function* () { + const firstMissing = `t3code_codex_first_`; + const secondMissing = `t3code_codex_second_`; + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { enabled: true, binaryPath: firstMissing }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, + }), + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + // `it.live` does not inherit layers from the outer `it.layer` + // wrapper, so provide `NodeServices.layer` inline. This is the + // same real `ChildProcessSpawner` + `FileSystem` + `Path` + // services that production uses. + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + // Boot-time probe: the default codex instance is enabled with + // `firstMissing`, so the real spawner yields ENOENT and the + // snapshot should be `status: "error"`. What *distinguishes* + // the two probe runs is `checkedAt` — each probe stamps a + // fresh DateTime, so we capture it and assert it advances + // after the settings mutation. + const initialProviders = yield* registry.getProviders; + const initialCodex = initialProviders.find( + (provider) => provider.instanceId === "codex", + ); + assert.strictEqual(initialCodex?.status, "error"); + assert.strictEqual(initialCodex?.installed, false); + const initialCheckedAt = initialCodex?.checkedAt; + assert.notStrictEqual(initialCheckedAt, undefined); + + // Drive a settings change. The Hydration layer's + // `SettingsWatcherLive` consumes this via `streamChanges`, + // calls `reconcile`, which rebuilds the codex instance (the + // envelope changed because `binaryPath` differs → `entryEqual` + // is false). The registry's `Stream.runForEach( + // instanceRegistry.streamChanges, () => syncLiveSources)` + // fires `syncLiveSources`, which subscribes + awaits a fresh + // refresh on the rebuilt instance. yield* serverSettings.updateSettings({ providers: { - codex: { - binaryPath: "/custom/codex", - }, + codex: { enabled: true, binaryPath: secondMissing }, }, }); - for (let attempt = 0; attempt < 20; attempt += 1) { - const updated = yield* registry.getProviders; - if (updated.find((status) => status.provider === "codex")?.status === "error") { - return; + // Poll with TestClock until `checkedAt` advances or we hit a + // generous virtual 3-second ceiling. + const refreshed = yield* Effect.gen(function* () { + for (let attempts = 0; attempts < 60; attempts += 1) { + const providers = yield* registry.getProviders; + const codex = providers.find((provider) => provider.instanceId === "codex"); + if (codex !== undefined && codex.checkedAt !== initialCheckedAt) { + return providers; + } + yield* TestClock.adjust("50 millis"); + yield* Effect.yieldNow; } - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); - } + return yield* registry.getProviders; + }); - const updated = yield* registry.getProviders; - assert.strictEqual( - updated.find((status) => status.provider === "codex")?.status, - "error", + const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); + assert.notStrictEqual( + reprobedCodex?.checkedAt, + initialCheckedAt, + "Expected a fresh probe after settings change, got the stale snapshot", ); + assert.strictEqual(reprobedCodex?.status, "error"); + assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); }), ); - it.effect("skips codex probes entirely when the provider is disabled", () => + it.effect("includes unavailable instance snapshots in getProviders", () => Effect.gen(function* () { - const serverSettingsLayer = ServerSettingsService.layerTest({ - providers: { - codex: { - enabled: false, - }, - }, - }); + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + opencode: { enabled: false }, + }, + providerInstances: { + ghost_main: { + driver: "ghostDriver", + displayName: "A fork-only driver we don't ship", + enabled: false, + config: { arbitrary: "payload" }, + }, + } as unknown as ContractServerSettings["providerInstances"], + }), + ), + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); + + assert.notStrictEqual(ghost, undefined); + assert.strictEqual(ghost?.driver, "ghostDriver"); + assert.strictEqual(ghost?.availability, "unavailable"); + assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect( + "keeps cursor disabled and skips probing when the provider setting is disabled", + () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + decodeServerSettings( + deepMerge(encodedDefaultServerSettings, { + providers: { + codex: { + enabled: false, + }, + cursor: { + enabled: false, + }, + }, + }), + ), + ); + let cursorSpawned = false; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + if (command === "agent") { + cursorSpawned = true; + } + const joined = args.join(" "); + if (joined === "--version") { + return { + stdout: `${command} 1.0.0\n`, + stderr: "", + code: 0, + }; + } + if (joined === "auth status") { + return { + stdout: '{"authenticated":true}\n', + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const cursorProvider = providers.find( + (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), + ); + + assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "claudeAgent", + "codex", + "cursor", + "opencode", + ]); + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorProvider?.status, "disabled"); + assert.strictEqual( + cursorProvider?.message, + "Cursor is disabled in T3 Code settings.", + ); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); - const status = yield* checkCodexProviderStatus().pipe( - Effect.provide( - Layer.mergeAll(serverSettingsLayer, failingSpawnerLayer("spawn codex ENOENT")), - ), + it.effect("skips codex probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), ); - assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.enabled, false); assert.strictEqual(status.status, "disabled"); assert.strictEqual(status.installed, false); @@ -650,248 +1326,119 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ); }); - // ── Custom model provider: checkCodexProviderStatus integration ─── + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); - describe("checkCodexProviderStatus with custom model provider", () => { it.effect( - "skips auth probe and returns ready when a custom model provider is configured", + "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", () => Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), ); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Using a custom Codex model provider; OpenAI login check skipped.", + const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); + if (!opus47) { + assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); + } + if (!opus47.capabilities) { + assert.fail( + "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", + ); + } + const effortDescriptor = opus47.capabilities.optionDescriptors?.find( + (descriptor) => descriptor.type === "select" && descriptor.id === "effort", + ); + assert.deepStrictEqual( + effortDescriptor?.type === "select" + ? effortDescriptor.options.find((option) => option.isDefault) + : undefined, + { id: "xhigh", label: "Extra High", isDefault: true }, ); }).pipe( Effect.provide( - // The spawner only handles --version; if the test attempts - // "login status" the throw proves the auth probe was NOT skipped. mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); }), ), ), ); - it.effect("still reports error when codex CLI is missing even with custom provider", () => + it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", ); - const status = yield* checkCodexProviderStatus(); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), - ); - }); - - describe("checkCodexProviderStatus with openai model provider", () => { - it.effect("still runs auth probe when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - const status = yield* checkCodexProviderStatus(); - // The auth probe runs and sees "not logged in" → error - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.auth.status, "unauthenticated"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; throw new Error(`Unexpected args: ${joined}`); }), ), ), ); - }); - - // ── parseAuthStatusFromOutput pure tests ────────────────────────── - - describe("parseAuthStatusFromOutput", () => { - it("exit code 0 with no auth markers is ready", () => { - const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.auth.status, "authenticated"); - }); - - it("JSON with authenticated=false is unauthenticated", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"authenticated":false}]\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.auth.status, "unauthenticated"); - }); - - it("JSON without auth marker is warning", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"ok":true}]\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.auth.status, "unknown"); - }); - }); - - // ── readCodexConfigModelProvider tests ───────────────────────────── - - describe("readCodexConfigModelProvider", () => { - it.effect("returns undefined when config file does not exist", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); - }), - ); - - it.effect("returns undefined when config has no model_provider key", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); - }), - ); - it.effect("returns the provider when model_provider is set at top level", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider(), "portkey"); - }), - ); - - it.effect("returns openai when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider(), "openai"); - }), - ); - - it.effect("ignores model_provider inside section headers", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model = "gpt-5-codex"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'model_provider = "should-be-ignored"', - "", - ].join("\n"), - ); - assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); - }), - ); - - it.effect("handles comments and whitespace", () => + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { - yield* withTempCodexHome( - [ - "# This is a comment", - "", - ' model_provider = "azure" ', - "", - "[profiles.deep-review]", - 'model = "gpt-5-pro"', - ].join("\n"), + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ subscriptionType: "maxplan" }), ); - assert.strictEqual(yield* readCodexConfigModelProvider(), "azure"); - }), - ); - - it.effect("handles single-quoted values in TOML", () => - Effect.gen(function* () { - yield* withTempCodexHome("model_provider = 'mistral'\n"); - assert.strictEqual(yield* readCodexConfigModelProvider(), "mistral"); - }), - ); - }); - - // ── hasCustomModelProvider tests ─────────────────────────────────── - - describe("hasCustomModelProvider", () => { - it.effect("returns false when no config file exists", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is not set", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns true when model_provider is portkey", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "portkey"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is azure", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "azure"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is ollama", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "ollama"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is a custom proxy", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - }); - - // ── checkClaudeProviderStatus tests ────────────────────────── - - describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "maxplan"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -909,14 +1456,58 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); - it.effect("returns a display label for claude subscription types", () => + it.effect("does not duplicate Claude in full subscription labels", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "ready"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max Subscription", + }), + ); assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "maxplan"); + assert.strictEqual(status.auth.type, "Claude Max Subscription"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("does not duplicate Claude in provider-prefixed subscription names", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "Claude Max", + }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "Claude Max"); assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns claude auth email from initialization result", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ email: "claude@example.com" }), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.email, "claude@example.com"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -924,7 +1515,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; if (joined === "auth status") return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', stderr: "", code: 0, }; @@ -934,18 +1526,50 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("runs Claude status probes with the configured Claude HOME", () => { + const claudeHome = "/tmp/t3code-claude-home"; + const recorded = recordingMockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + homePath: claudeHome, + }, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.deepStrictEqual( + recorded.commands.map((command) => command.env?.HOME), + [claudeHome], + ); + }).pipe(Effect.provide(recorded.layer)); + }); + it.effect("includes probed claude slash commands in the provider snapshot", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( - () => Effect.succeed("maxplan"), - () => - Effect.succeed([ + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ { name: "review", description: "Review a pull request", input: { hint: "pr-or-branch" }, }, - ]), + ], + }), ); assert.deepStrictEqual(status.slashCommands, [ @@ -975,9 +1599,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("deduplicates probed claude slash commands by name", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( - () => Effect.succeed("maxplan"), - () => - Effect.succeed([ + defaultClaudeSettings, + claudeCapabilities({ + subscriptionType: "maxplan", + slashCommands: [ { name: "ui", description: "Explore and refine UI", @@ -986,7 +1611,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( name: "ui", input: { hint: "component-or-screen" }, }, - ]), + ], + }), ); assert.deepStrictEqual(status.slashCommands, [ @@ -1015,8 +1641,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), + ); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "apiKey"); @@ -1040,8 +1668,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns unavailable when claude is missing", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); @@ -1054,8 +1684,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it.effect("returns error when version check fails with non-zero exit code", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); }).pipe( @@ -1063,33 +1695,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") - return { stdout: "", stderr: "Something went wrong", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when auth status reports not logged in", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unauthenticated"); - assert.strictEqual( - status.message, - "Claude is not authenticated. Run `claude auth login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") return { - stdout: '{"loggedIn":false}\n', - stderr: "", + stdout: "", + stderr: "Something went wrong", code: 1, }; throw new Error(`Unexpected args: ${joined}`); @@ -1098,36 +1706,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); - it.effect("returns unauthenticated when output includes 'not logged in'", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unauthenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when auth status command is unsupported", () => + it.effect("returns warning when the Claude initialization result is unavailable", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual(status.provider, "claudeAgent"); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + noClaudeCapabilities, + ); assert.strictEqual(status.status, "warning"); assert.strictEqual(status.installed, true); assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, - "Claude Agent authentication status command is unavailable in this version of Claude.", + "Could not verify Claude authentication status from initialization result.", ); }).pipe( Effect.provide( @@ -1135,52 +1725,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; if (joined === "auth status") - return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; throw new Error(`Unexpected args: ${joined}`); }), ), ), ); }); - - // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── - - describe("parseClaudeAuthStatusFromOutput", () => { - it("exit code 0 with no auth markers is ready", () => { - const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.auth.status, "authenticated"); - }); - - it("JSON with loggedIn=true is authenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.auth.status, "authenticated"); - }); - - it("JSON with loggedIn=false is unauthenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.auth.status, "unauthenticated"); - }); - - it("JSON without auth marker is warning", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"ok":true}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.auth.status, "unknown"); - }); - }); }, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fb2f33c2932..22a120f0a52 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -1,96 +1,695 @@ /** - * ProviderRegistryLive - Aggregates provider-specific snapshot services. + * ProviderRegistryLive — aggregates per-instance snapshot streams into a + * single materialized list. + * + * Historically this Layer composed four per-kind Live Layers + * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a + * `ServerProviderShape`. Those Lives were deleted during the driver / + * instance refactor — every driver now carries its `snapshot: ServerProviderShape` + * bundled onto the `ProviderInstance` the registry produces. + * + * Each configured instance (including multi-instance setups like + * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, + * keyed by `instanceId`. Instances whose driver is unavailable or whose + * config failed to decode are merged from `instanceRegistry.listUnavailable` + * as shadow snapshots so the UI can render their exact unavailable reason. + * + * Cache paths on disk are now keyed by `instanceId`. Because + * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing + * `.json` files remain the on-disk location for that driver's default + * instance. Identity-less legacy cache contents are ignored and replaced by + * the first live refresh. * * @module ProviderRegistryLive */ -import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; -import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdateState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; -import { ClaudeProviderLive } from "./ClaudeProvider"; -import { CodexProviderLive } from "./CodexProvider"; -import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import type { CodexProviderShape } from "../Services/CodexProvider"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; +import { ServerConfig } from "../../config.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; +import { + hydrateCachedProvider, + isCachedProviderCorrelated, + orderProviderSnapshots, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "../providerStatusCache.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; const loadProviders = ( - codexProvider: CodexProviderShape, - claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { - concurrency: "unbounded", + providerSources: ReadonlyArray, +): Effect.Effect> => + Effect.forEach( + providerSources, + (providerSource) => + providerSource.getSnapshot.pipe( + Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), + ), + { + concurrency: "unbounded", + }, + ); + +const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) => + makeManualOnlyProviderMaintenanceCapabilities({ + provider, + packageName: null, }); +const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => + (model.capabilities?.optionDescriptors?.length ?? 0) > 0; + +const mergeProviderModels = ( + previousModels: ReadonlyArray, + nextModels: ReadonlyArray, +): ReadonlyArray => { + if (nextModels.length === 0 && previousModels.length > 0) { + return previousModels; + } + + const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); + const mergedModels = nextModels.map((model) => { + const previousModel = previousBySlug.get(model.slug); + if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { + return model; + } + return { + ...model, + capabilities: previousModel.capabilities, + }; + }); + const nextSlugs = new Set(nextModels.map((model) => model.slug)); + return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; +}; + +export const mergeProviderSnapshot = ( + previousProvider: ServerProvider | undefined, + nextProvider: ServerProvider, +): ServerProvider => + !previousProvider + ? nextProvider + : { + ...nextProvider, + models: mergeProviderModels(previousProvider.models, nextProvider.models), + }; + +export const mergeProviderSnapshots = ( + previousProviders: ReadonlyArray, + nextProviders: ReadonlyArray, +): ReadonlyArray => { + const mergedProviders = new Map( + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), + ); + + for (const provider of nextProviders) { + mergedProviders.set( + snapshotInstanceKey(provider), + mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), + ); + } + + return orderProviderSnapshots([...mergedProviders.values()]); +}; + +export const selectProvidersByKind = ( + providers: ReadonlyArray, + providerKinds: ReadonlySet, +): ReadonlyArray => + providers.filter((provider) => providerKinds.has(provider.driver)); + export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); +const correlateSnapshotWithSource = ( + source: ProviderSnapshotSource, + snapshot: ServerProvider, +): Effect.Effect => { + if (snapshot.instanceId !== source.instanceId) { + return Effect.die( + new Error( + `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, + ), + ); + } + if (snapshot.driver !== source.driverKind) { + return Effect.die( + new Error( + `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, + ), + ); + } + return Effect.succeed(snapshot); +}; + +/** + * Key a snapshot for aggregation and persistence. Snapshot sources + * must be correlated by instance id before reaching this map; missing + * identities are defects, not runtime routing fallbacks. + */ +const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { + return provider.instanceId; +}; + +// Project a live `ProviderInstance` into the aggregator's consumption +// shape. Each call re-captures the instance's `snapshot` closures, so +// after `ProviderInstanceRegistry` rebuilds an instance (e.g. because +// its settings changed), a fresh source rides the new PubSub instead +// of a closed one. +const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + getSnapshot: instance.snapshot.getSnapshot, + refresh: instance.snapshot.refresh, + streamChanges: instance.snapshot.streamChanges, +}); + export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { - const codexProvider = yield* CodexProvider; - const claudeProvider = yield* ClaudeProvider; + const instanceRegistry = yield* ProviderInstanceRegistry; + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for + // coalesced updates across every instance. const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); - const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + + // Boot-only: hydrate `providersRef` from the on-disk per-instance + // cache so the UI has something to render during the first refresh. + // Instances added post-boot skip this path; their first entry in + // `providersRef` comes from the reactive `syncLiveSources` pass + // below. + const bootInstances = yield* instanceRegistry.listInstances; + const bootSources = bootInstances.map(buildSnapshotSource); + const fallbackProviders = yield* loadProviders(bootSources); + const fallbackByInstance = new Map(); + for (let index = 0; index < fallbackProviders.length; index++) { + const provider = fallbackProviders[index]; + const source = bootSources[index]; + if (provider === undefined || source === undefined) { + continue; + } + fallbackByInstance.set(source.instanceId, provider); + } + + const cachedProviders = yield* Effect.forEach( + bootSources, + (source) => + Effect.gen(function* () { + // One cache file per configured instance. For the default + // instance of a built-in kind the path equals `.json` — + // identical to the legacy filename. We still require the cache + // payload to carry matching instance id + driver kind; old + // identity-less payloads are discarded and the awaited refresh + // below repopulates the cache. + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: source.instanceId, + }).pipe(Effect.provideService(Path.Path, path)); + const fallbackProvider = fallbackByInstance.get(source.instanceId); + if (fallbackProvider === undefined) { + return undefined; + } + return yield* readProviderStatusCache(filePath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.flatMap((cachedProvider) => { + if (cachedProvider === undefined) { + return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); + } + const correlation = { + cachedProvider, + fallbackProvider, + } as const; + if (!isCachedProviderCorrelated(correlation)) { + return Effect.logWarning("provider status cache identity mismatch, ignoring", { + path: filePath, + instanceId: source.instanceId, + cachedInstanceId: cachedProvider.instanceId ?? null, + driver: source.driverKind, + cachedDriver: cachedProvider.driver ?? null, + }).pipe(Effect.as(undefined as ServerProvider | undefined)); + } + return Effect.succeed(hydrateCachedProvider(correlation)); + }), + ); + }), + { concurrency: "unbounded" }, + ).pipe( + Effect.map((providers) => + orderProviderSnapshots( + providers.filter((provider): provider is ServerProvider => provider !== undefined), + ), + ), + ); + const providersRef = yield* Ref.make>(cachedProviders); + const maintenanceActionStatesRef = yield* Ref.make< + ReadonlyMap + >(new Map()); + + // Live-source registry — the dynamic counterpart to the boot-time + // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` + // reference is used for identity equality so "no-op" reconciles + // (settings unchanged) skip re-subscribing + re-probing. + const liveSubsRef = yield* Ref.make>( + new Map(), ); + // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't + // interleave two passes clobbering each other's fiber bookkeeping. + const syncSemaphore = yield* Semaphore.make(1); + + const getLiveSources: Effect.Effect> = Ref.get( + liveSubsRef, + ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); - const syncProviders = Effect.fn("syncProviders")(function* (options?: { - readonly publish?: boolean; - }) { - const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); - yield* Ref.set(providersRef, providers); + const persistProvider = (provider: ServerProvider) => + Effect.gen(function* () { + // Persist every instance — the file name is the instance id, so + // multi-instance setups (e.g. `codex_personal`, `codex_work`) each + // get their own cache. We resolve the path fresh so snapshots + // produced by newly-added instances post-boot still land on disk + // without the aggregator holding a stale `cachePathByInstance` + // entry. + const key = snapshotInstanceKey(provider); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: key, + }).pipe(Effect.provideService(Path.Path, path)); + yield* writeProviderStatusCache({ filePath, provider }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ); + }); - if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { - yield* PubSub.publish(changesPubSub, providers); + const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( + provider: ServerProvider, + ) { + const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); + const updateState = maintenanceActionStates.get(provider.instanceId)?.update; + if (!updateState) { + const { updateState: _updateState, ...providerWithoutUpdateState } = provider; + return providerWithoutUpdateState; + } + return { + ...provider, + updateState, + }; + }); + + const upsertProviders = Effect.fn("upsertProviders")(function* ( + nextProviders: ReadonlyArray, + options?: { + readonly publish?: boolean; + readonly persist?: boolean; + readonly replace?: boolean; + }, + ) { + const nextProvidersWithUpdateState = yield* Effect.forEach( + nextProviders, + applyProviderUpdateState, + { + concurrency: "unbounded", + }, + ); + const [previousProviders, providers, providersToPersist] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const mergedProviders = new Map( + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), + ); + const updatedKeys = new Set(); + + for (const provider of nextProvidersWithUpdateState) { + const key = snapshotInstanceKey(provider); + updatedKeys.add(key); + mergedProviders.set( + key, + options?.replace === true + ? provider + : mergeProviderSnapshot(mergedProviders.get(key), provider), + ); + } + + const providers = orderProviderSnapshots([...mergedProviders.values()]); + const providersToPersist = providers.filter((provider) => + updatedKeys.has(snapshotInstanceKey(provider)), + ); + return [[previousProviders, providers, providersToPersist] as const, providers]; + }, + ); + + if (haveProvidersChanged(previousProviders, providers)) { + if (options?.persist !== false) { + yield* Effect.forEach(providersToPersist, persistProvider, { + concurrency: "unbounded", + discard: true, + }); + } + if (options?.publish !== false) { + yield* PubSub.publish(changesPubSub, providers); + } } return providers; }); - yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, + const syncProvider = Effect.fn("syncProvider")(function* ( + provider: ServerProvider, + options?: { + readonly publish?: boolean; + }, + ) { + return yield* upsertProviders([provider], options); + }); + + const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly action: "update"; + readonly state: ServerProviderUpdateState | null; + }) { + yield* Ref.update(maintenanceActionStatesRef, (previous) => { + const previousActions = previous.get(input.instanceId); + const nextActions = { ...previousActions }; + if (input.state === null || input.state.status === "idle") { + delete nextActions[input.action]; + } else { + nextActions[input.action] = input.state; + } + + const next = new Map(previous); + if (Object.keys(nextActions).length === 0) { + next.delete(input.instanceId); + } else { + next.set(input.instanceId, nextActions); + } + return next; + }); + + const existingProviders = yield* Ref.get(providersRef); + const matchingProvider = existingProviders.find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!matchingProvider) { + return existingProviders; + } + + const nextProvider = yield* applyProviderUpdateState(matchingProvider); + return yield* upsertProviders([nextProvider], { + persist: false, + }); + }, + ); + + const refreshOneSource = Effect.fn("refreshOneSource")(function* ( + providerSource: ProviderSnapshotSource, + ) { + return yield* providerSource.refresh.pipe( + Effect.flatMap((nextProvider) => + correlateSnapshotWithSource(providerSource, nextProvider).pipe( + Effect.flatMap(syncProvider), + ), + ), + ); + }); + + const refreshAll = Effect.fn("refreshAll")(function* () { + const sources = yield* getLiveSources; + return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.andThen(Ref.get(providersRef))); + }); + + const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { + if (provider === undefined) { + return yield* refreshAll(); + } + // Kind-scoped refreshes target the default instance for that driver. + const defaultInstanceId = defaultInstanceIdForDriver(provider); + const sources = yield* getLiveSources; + const providerSource = sources.find( + (candidate) => candidate.instanceId === defaultInstanceId, + ); + if (!providerSource) { + return yield* Ref.get(providersRef); + } + return yield* refreshOneSource(providerSource); + }); + + const refreshInstance = Effect.fn("refreshInstance")(function* ( + instanceId: ProviderInstanceId, + ) { + const sources = yield* getLiveSources; + const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); + if (!providerSource) { + return yield* Ref.get(providersRef); + } + return yield* refreshOneSource(providerSource); + }); + + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( + "getProviderMaintenanceCapabilitiesForInstance", + )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === instanceId, + ); + return ( + instance?.snapshot.maintenanceCapabilities ?? + makeManualProviderMaintenanceCapabilities(provider) + ); + }); + + /** + * Diff the aggregator's live-source set against the current + * `ProviderInstanceRegistry` and: + * - subscribe to each newly-added or rebuilt instance's + * `streamChanges` (so periodic + enrichment refreshes land in + * `providersRef`); + * - force-refresh each newly-added/rebuilt instance and feed the + * result directly into `providersRef`, bypassing the PubSub + * attachment race that otherwise drops the initial probe; + * - prune `providersRef` of instances that no longer exist. + * + * Initial refreshes are awaited in parallel rather than forked, so + * callers (layer build; `streamChanges` watcher) see fully-probed + * state on return. This matters for layer build in particular: + * consumers reading `getProviders` immediately after layer build + * expect the probe to have already landed. + * + * Per-instance subscription fibers are not tracked explicitly. When + * a rebuilt instance's old child scope closes, its PubSub shuts + * down and our `Stream.runForEach` fiber exits naturally. + */ + const syncLiveSources = syncSemaphore.withPermits(1)( + Effect.gen(function* () { + const instances = yield* instanceRegistry.listInstances; + const unavailableProviders = yield* instanceRegistry.listUnavailable; + const nextByInstance = new Map( + instances.map((instance) => [instance.instanceId, instance] as const), + ); + const knownInstanceIds = new Set(nextByInstance.keys()); + for (const provider of unavailableProviders) { + knownInstanceIds.add(snapshotInstanceKey(provider)); + } + const previousSubs = yield* Ref.get(liveSubsRef); + + // Carry over subscriptions for instances whose identity is + // unchanged (reconcile treated them as no-op). Instances that + // disappeared, or were rebuilt with a different reference, + // fall through to the "newly-added" branch below. + const carriedOver = new Map(); + for (const [instanceId, previousInstance] of previousSubs) { + const nextInstance = nextByInstance.get(instanceId); + if (nextInstance !== undefined && nextInstance === previousInstance) { + carriedOver.set(instanceId, previousInstance); + } + } + + // Collect new/rebuilt instances in `nextByInstance` insertion + // order (which preserves settings-author order). + const newlyAdded: Array = []; + for (const [instanceId, instance] of nextByInstance) { + if (carriedOver.has(instanceId)) { + continue; + } + newlyAdded.push([instanceId, instance] as const); + } + + // Fork long-lived subscriptions to each new/rebuilt instance's + // change stream BEFORE kicking off refreshes — if the driver's + // own initial probe (line 140 in `makeManagedServerProvider`) + // wins the refreshSemaphore race, its PubSub publish must land + // in an active subscriber or the result is dropped. + for (const [, instance] of newlyAdded) { + const source = buildSnapshotSource(instance); + yield* Stream.runForEach(source.streamChanges, (provider) => + correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), + ).pipe(Effect.forkScoped); + } + + // Force-refresh every new/rebuilt instance in parallel and wait + // for them all to complete. The refresh's result is piped + // directly into `syncProvider`, so `providersRef` is populated + // deterministically by the time this block returns — regardless + // of PubSub subscription timing. Failures are logged and + // swallowed so one bad driver can't wedge the whole registry. + yield* Effect.forEach( + newlyAdded, + ([, instance]) => + refreshOneSource(buildSnapshotSource(instance)).pipe(Effect.ignoreCause({ log: true })), + { concurrency: "unbounded", discard: true }, + ); + yield* upsertProviders(unavailableProviders, { + persist: false, + replace: true, + }); + + const nextSubs = new Map(carriedOver); + for (const [instanceId, instance] of newlyAdded) { + nextSubs.set(instanceId, instance); + } + yield* Ref.set(liveSubsRef, nextSubs); + + // Drop aggregator state for instances that have disappeared — + // otherwise the UI would keep rendering ghosts. + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = orderProviderSnapshots( + previousProviders.filter((provider) => + knownInstanceIds.has(snapshotInstanceKey(provider)), + ), + ); + return [[previousProviders, providers] as const, providers]; + }, + ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + yield* Ref.update(maintenanceActionStatesRef, (previous) => { + const next = new Map(previous); + for (const instanceId of previous.keys()) { + if (!knownInstanceIds.has(instanceId)) { + next.delete(instanceId); + } + } + return next; + }); + }), ); - yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, + const syncLiveSourcesAndContinue = syncLiveSources.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logError( + "provider registry instance sync failed; keeping subscription alive", + { + cause: Cause.pretty(cause), + }, + ); + }), ); - const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { - switch (provider) { - case "codex": - yield* codexProvider.refresh; - break; - case "claudeAgent": - yield* claudeProvider.refresh; - break; - default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); - break; + // Seed `providersRef` with the boot-time fallback snapshots so + // consumers calling `getProviders` immediately after layer build see + // a populated list — even before the first `syncLiveSources` refresh + // resolves. Cached snapshots (already in `providersRef`) merge with + // these via `upsertProviders` so on-disk state wins where present + // and pending fallbacks fill the gaps. + yield* upsertProviders(fallbackProviders, { publish: false }); + // Subscribe to registry mutations BEFORE running the initial sync. + // `subscribeChanges` acquires the dequeue synchronously in this + // fibre; the subscription is active the instant this `yield*` + // returns. Forking the consumer loop later cannot lose a publish + // because no publish can reach a not-yet-subscribed dequeue. + // + // (Contrast with the pre-fix code that did + // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. + // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, + // and `forkScoped` only schedules the fibre — so a reconcile that + // published between "fibre scheduled" and "fibre starts running" + // was dropped, which made any settings change that replaced an + // instance never propagate to the aggregator's `providersRef`.) + // Subscribe to registry mutations BEFORE running the initial sync. + // `subscribeChanges` acquires the `PubSub.Subscription` synchronously + // in this fibre; the subscription is registered with the PubSub the + // instant this `yield*` returns, so any subsequent publish is + // buffered in the subscription regardless of when the consumer + // fibre below actually starts running. + // + // (Contrast with the pre-fix code that did + // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. + // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`, + // which defers `PubSub.subscribe` to stream start. `forkScoped` only + // schedules the consumer fibre — so a reconcile that published + // between "fibre scheduled" and "fibre starts running + subscribes" + // was dropped, which made any settings change that replaced an + // instance never propagate to the aggregator's `providersRef`.) + const instanceChanges = yield* instanceRegistry.subscribeChanges; + // Initial sync: subscribe + kick off refreshes for every instance + // present at boot. Run synchronously so consumers pulling immediately + // after the layer build see the correct aggregator state. + yield* syncLiveSources; + // React to registry mutations — instance added / removed / rebuilt. + // `Stream.fromSubscription` builds a stream over the pre-acquired + // subscription rather than subscribing on stream start, which is + // what closes the race. + yield* Stream.runForEach( + Stream.fromSubscription(instanceChanges), + () => syncLiveSourcesAndContinue, + ).pipe(Effect.forkScoped); + + const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* ( + cause: Cause.Cause, + ) { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.interrupt; } - return yield* syncProviders(); + yield* Effect.logError("provider registry refresh failed; preserving cached providers", { + cause: Cause.pretty(cause), + }); + return yield* Ref.get(providersRef); }); return { - getProviders: syncProviders({ publish: false }).pipe( - Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => []), - ), - refresh: (provider?: ProviderKind) => - refresh(provider).pipe( - Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => []), - ), + getProviders: Ref.get(providersRef), + refresh: (provider?: ProviderDriverKind) => + refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstance: (instanceId: ProviderInstanceId) => + refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + getProviderMaintenanceCapabilitiesForInstance, + setProviderMaintenanceActionState, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 56f9f8d65c4..fc0450b8b69 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -12,28 +13,44 @@ import type { import { ApprovalRequestId, EventId, - type ProviderKind, + ProviderDriverKind, + ProviderInstanceId, ProviderSessionStartInput, ThreadId, TurnId, } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, vi } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; +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 Metric from "effect/Metric"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { + ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderUnsupportedError, ProviderValidationError, type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; -import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { + ProviderAdapterRegistry, + type ProviderAdapterRegistryShape, +} from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "./ProviderService.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; @@ -44,6 +61,7 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettingsService.layerTest(); @@ -51,11 +69,16 @@ const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make const asEventId = (value: string): EventId => EventId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +const codexInstanceId = ProviderInstanceId.make("codex"); +const claudeAgentInstanceId = ProviderInstanceId.make("claudeAgent"); +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -65,19 +88,24 @@ type LegacyProviderRuntimeEvent = { readonly [key: string]: unknown; }; -function makeFakeCodexAdapter(provider: ProviderKind = "codex") { +function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { const sessions = new Map(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const startSession = vi.fn((input: ProviderSessionStartInput) => Effect.sync(() => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const session: ProviderSession = { provider, + ...(input.providerInstanceId !== undefined + ? { providerInstanceId: input.providerInstanceId } + : {}), status: "ready", runtimeMode: input.runtimeMode, threadId: input.threadId, - resumeCursor: input.resumeCursor ?? { opaque: `resume-${String(input.threadId)}` }, + resumeCursor: input.resumeCursor ?? { + opaque: `resume-${String(input.threadId)}`, + }, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -229,8 +257,8 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { }; } -const sleep = (ms: number) => - Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +const advanceTestClock = (ms: number) => + TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); const hasMetricSnapshot = ( snapshots: ReadonlyArray, @@ -245,16 +273,13 @@ const hasMetricSnapshot = ( function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); - const claude = makeFakeCodexAdapter("claudeAgent"); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : provider === "claudeAgent" - ? Effect.succeed(claude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex", "claudeAgent"]), - }; + const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const cursor = makeFakeCodexAdapter(CURSOR_DRIVER); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + [ProviderDriverKind.make("claudeAgent")]: claude.adapter, + [ProviderDriverKind.make("cursor")]: cursor.adapter, + }); const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( @@ -269,6 +294,7 @@ function makeProviderServiceLayer() { Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ), directoryLayer, @@ -280,31 +306,81 @@ function makeProviderServiceLayer() { return { codex, claude, + cursor, layer, }; } +it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + codex.stopAll.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: String(CODEX_DRIVER), + method: "stopAll", + detail: "simulated stopAll failure", + }), + ), + ); + const registry = makeAdapterRegistryMock({ + [CODEX_DRIVER]: codex.adapter, + }); + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provideMerge(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ), + directoryLayer, + runtimeRepositoryLayer, + NodeServices.layer, + ); + const scope = yield* Scope.make(); + const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + yield* ProviderService; + }).pipe(Effect.provide(runtimeServices)); + const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); + + assert.equal(Exit.isSuccess(closeExit), true); + assert.equal(codex.stopAll.mock.calls.length, 1); + }), +); + it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); - const claude = makeFakeCodexAdapter("claudeAgent"); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : provider === "claudeAgent" - ? Effect.succeed(claude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex", "claudeAgent"]), + const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const registryBase = makeAdapterRegistryMock({ + [CODEX_DRIVER]: codex.adapter, + [CLAUDE_AGENT_DRIVER]: claude.adapter, + }); + const registry: ProviderAdapterRegistryShape = { + ...registryBase, + getInstanceInfo: (instanceId) => + instanceId === claudeAgentInstanceId + ? Effect.succeed({ + instanceId, + driverKind: CLAUDE_AGENT_DRIVER, + displayName: undefined, + enabled: false, + continuationIdentity: { + driverKind: CLAUDE_AGENT_DRIVER, + continuationKey: "claudeAgent:instance:claudeAgent", + }, + }) + : registryBase.getInstanceInfo(instanceId), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const serverSettingsLayer = ServerSettingsService.layerTest({ - providers: { - claudeAgent: { - enabled: false, - }, - }, - }); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); @@ -312,15 +388,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () const providerLayer = makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(serverSettingsLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const failure = yield* Effect.flip( Effect.gen(function* () { const provider = yield* ProviderService; return yield* provider.startSession(asThreadId("thread-disabled"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-disabled"), runtimeMode: "full-access", }); @@ -328,25 +406,212 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () ); assert.instanceOf(failure, ProviderValidationError); - assert.include(failure.issue, "Provider 'claudeAgent' is disabled in T3 Code settings."); + assert.include(failure.issue, "Provider instance 'claudeAgent' is disabled"); assert.equal(claude.startSession.mock.calls.length, 0); }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect( + "ProviderServiceLive allows enabled custom instances when legacy driver is disabled", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_personal"); + const driverKind = CODEX_DRIVER; + const codex = makeFakeCodexAdapter(); + const unsupported = () => + new ProviderUnsupportedError({ + provider: driverKind, + }); + const registry: ProviderAdapterRegistryShape = { + getByInstance: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed(codex.adapter) + : Effect.fail(unsupported()), + getInstanceInfo: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed({ + instanceId, + driverKind, + displayName: "Codex Personal", + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: "codex:/Users/example/.codex", + }, + }) + : Effect.fail(unsupported()), + listInstances: () => Effect.succeed([instanceId]), + listProviders: () => Effect.succeed([driverKind] as const), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + codex: { + enabled: false, + }, + }, + }); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(serverSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + const session = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-enabled-custom"), { + provider: driverKind, + providerInstanceId: instanceId, + threadId: asThreadId("thread-enabled-custom"), + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)); + + assert.equal(session.providerInstanceId, instanceId); + assert.equal(codex.startSession.mock.calls.length, 1); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("ProviderServiceLive rejects new sessions for disabled custom instances", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_personal"); + const driverKind = ProviderDriverKind.make("codex"); + const codex = makeFakeCodexAdapter(); + const unsupported = () => + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make("codex"), + }); + const registry: ProviderAdapterRegistryShape = { + getByInstance: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed(codex.adapter) + : Effect.fail(unsupported()), + getInstanceInfo: (requestedInstanceId) => + requestedInstanceId === instanceId + ? Effect.succeed({ + instanceId, + driverKind, + displayName: "Codex Personal", + enabled: false, + continuationIdentity: { + driverKind, + continuationKey: "codex:/Users/example/.codex", + }, + }) + : Effect.fail(unsupported()), + listInstances: () => Effect.succeed([instanceId]), + listProviders: () => Effect.succeed([CODEX_DRIVER] as const), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + const failure = yield* Effect.flip( + Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-disabled-instance"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId: asThreadId("thread-disabled-instance"), + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Provider instance 'codex_personal' is disabled"); + assert.equal(codex.startSession.mock.calls.length, 0); + }).pipe(Effect.provide(NodeServices.layer)), +); + const routing = makeProviderServiceLayer(); + +it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const canonicalEvents: ProviderRuntimeEvent[] = []; + const canonicalThreadIds: Array = []; + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + }); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = makeProviderServiceLive({ + canonicalEventLogger: { + filePath: "memory://provider-canonical-events", + write: (event, threadId) => { + canonicalEvents.push(event as ProviderRuntimeEvent); + canonicalThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }, + }).pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + yield* Effect.gen(function* () { + yield* ProviderService; + yield* advanceTestClock(10); + codex.emit({ + eventId: asEventId("evt-canonical-thread-segment"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-canonical-thread-segment"), + createdAt: "2026-01-01T00:00:00.000Z", + type: "turn.completed", + payload: { + state: "completed", + }, + }); + yield* advanceTestClock(20); + }).pipe(Effect.provide(providerLayer)); + + assert.equal(canonicalEvents.length, 1); + assert.equal(canonicalEvents[0]?.threadId, "thread-canonical-thread-segment"); + assert.deepEqual(canonicalThreadIds, ["thread-canonical-thread-segment"]); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const codex = makeFakeCodexAdapter(); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + }); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( @@ -357,7 +622,8 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: ThreadId.make("thread-stale"), }); }).pipe(Effect.provide(directoryLayer)); @@ -367,6 +633,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); yield* Effect.gen(function* () { @@ -381,7 +648,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const runtime = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") }); + return yield* repository.getByThreadId({ + threadId: asThreadId("thread-stale"), + }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(runtime), true); @@ -411,13 +680,9 @@ it.effect( ); const firstCodex = makeFakeCodexAdapter(); - const firstRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(firstCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const firstRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: firstCodex.adapter, + }); const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), @@ -427,6 +692,7 @@ it.effect( Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const updatedResumeCursor = { threadId: asThreadId("thread-1"), @@ -439,7 +705,8 @@ it.effect( const provider = yield* ProviderService; const threadId = asThreadId("thread-1"); const session = yield* provider.startSession(threadId, { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, cwd: "/tmp/project", runtimeMode: "full-access", threadId, @@ -448,14 +715,16 @@ it.effect( ...existing, status: "ready", resumeCursor: updatedResumeCursor, - updatedAt: new Date(Date.now() + 1_000).toISOString(), + updatedAt: "2026-01-01T00:00:01.000Z", })); return session; }).pipe(Effect.provide(firstProviderLayer)); const persistedAfterStopAll = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getByThreadId({ threadId: startedSession.threadId }); + return yield* repository.getByThreadId({ + threadId: startedSession.threadId, + }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(persistedAfterStopAll), true); if (Option.isSome(persistedAfterStopAll)) { @@ -464,13 +733,9 @@ it.effect( } const secondCodex = makeFakeCodexAdapter(); - const secondRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(secondCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const secondRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: secondCodex.adapter, + }); const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -479,6 +744,7 @@ it.effect( Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); secondCodex.startSession.mockClear(); @@ -522,7 +788,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), cwd: "/tmp/project", runtimeMode: "full-access", @@ -574,20 +841,31 @@ routing.layer("ProviderServiceLive routing", (it) => { }); yield* provider.stopSession({ threadId: session.threadId }); - const sendAfterStop = yield* Effect.result( - provider.sendTurn({ - threadId: session.threadId, - input: "after-stop", - attachments: [], - }), - ); - assertFailure( - sendAfterStop, - new ProviderValidationError({ - operation: "ProviderService.sendTurn", - issue: `Cannot route thread '${session.threadId}' because no persisted provider binding exists.`, - }), - ); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: session.threadId, + input: "after-stop", + attachments: [], + }); + + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "codex"); + assert.equal(startPayload.cwd, "/tmp/project"); + assert.deepEqual(startPayload.resumeCursor, session.resumeCursor); + assert.equal(startPayload.threadId, session.threadId); + } + assert.equal(routing.codex.sendTurn.mock.calls.length, 1); }), ); @@ -596,7 +874,8 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), cwd: "/tmp/project", runtimeMode: "full-access", @@ -631,12 +910,65 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("preserves the persisted binding when stopping a session", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-reap-preserve"), + cwd: "/tmp/project-reap-preserve", + runtimeMode: "full-access", + }); + + yield* provider.stopSession({ threadId: initial.threadId }); + + const persistedAfterStop = yield* runtimeRepository.getByThreadId({ + threadId: initial.threadId, + }); + assert.equal(Option.isSome(persistedAfterStop), true); + if (Option.isSome(persistedAfterStop)) { + assert.equal(persistedAfterStop.value.status, "stopped"); + assert.deepEqual(persistedAfterStop.value.resumeCursor, initial.resumeCursor); + } + + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: initial.threadId, + input: "resume after reap", + attachments: [], + }); + + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "codex"); + assert.equal(startPayload.cwd, "/tmp/project-reap-preserve"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + assert.equal(routing.codex.sendTurn.mock.calls.length, 1); + }), + ); + it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-claude"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude"), cwd: "/tmp/project-claude", runtimeMode: "full-access", @@ -647,19 +979,95 @@ routing.layer("ProviderServiceLive routing", (it) => { const startInput = routing.claude.startSession.mock.calls[0]?.[0]; assert.equal(typeof startInput === "object" && startInput !== null, true); if (startInput && typeof startInput === "object") { - const startPayload = startInput as { provider?: string; cwd?: string }; + const startPayload = startInput as { + provider?: string; + providerInstanceId?: ProviderInstanceId; + cwd?: string; + }; assert.equal(startPayload.provider, "claudeAgent"); + assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId); assert.equal(startPayload.cwd, "/tmp/project-claude"); } }), ); + it.effect("dies when an active session conflicts with its persisted binding", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const threadId = asThreadId("thread-binding-mismatch"); + + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-binding-mismatch", + runtimeMode: "full-access", + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + runtimeMode: "full-access", + }); + + const exit = yield* Effect.exit(provider.listSessions()); + assert.equal(Exit.hasDies(exit), true); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + }), + ); + + it.effect("stops stale sessions in other providers after a successful replacement start", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = asThreadId("thread-provider-replacement"); + + const codexSession = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + routing.codex.stopSession.mockClear(); + routing.claude.stopSession.mockClear(); + + const claudeSession = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + assert.equal(codexSession.provider, "codex"); + assert.equal(claudeSession.provider, "claudeAgent"); + assert.deepEqual(routing.codex.stopSession.mock.calls, [[threadId]]); + assert.equal(routing.claude.stopSession.mock.calls.length, 0); + + const sessions = yield* provider.listSessions(); + assert.deepEqual( + sessions + .filter((session) => session.threadId === threadId) + .map((session) => session.provider), + ["claudeAgent"], + ); + }), + ); + it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { const provider = yield* ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), cwd: "/tmp/project-send-turn", runtimeMode: "full-access", @@ -699,16 +1107,15 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-send-turn"), cwd: "/tmp/project-claude-send-turn", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), runtimeMode: "full-access", }); @@ -735,13 +1142,12 @@ routing.layer("ProviderServiceLive routing", (it) => { }; assert.equal(startPayload.provider, "claudeAgent"); assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); - assert.deepEqual(startPayload.modelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }); + assert.deepEqual( + startPayload.modelSelection, + createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + { id: "effort", value: "max" }, + ]), + ); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -754,12 +1160,14 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); yield* provider.startSession(asThreadId("thread-2"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-2"), runtimeMode: "full-access", }); @@ -777,9 +1185,11 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", - threadId: asThreadId("thread-1"), + const threadId = asThreadId("thread-runtime-status"); + const session = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, runtimeMode: "full-access", }); yield* provider.sendTurn({ @@ -805,7 +1215,7 @@ routing.layer("ProviderServiceLive routing", (it) => { lastError: string | null; lastRuntimeEvent: string | null; }; - assert.equal(runtimePayload.cwd, process.cwd()); + assert.equal(runtimePayload.cwd, session.cwd); assert.equal(runtimePayload.model, null); assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); assert.equal(runtimePayload.lastError, null); @@ -824,14 +1234,10 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(persistenceLayer), ); - const firstClaude = makeFakeCodexAdapter("claudeAgent"); - const firstRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "claudeAgent" - ? Effect.succeed(firstClaude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["claudeAgent"]), - }; + const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const firstRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, + }); const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -840,12 +1246,14 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); const initial = yield* Effect.gen(function* () { const provider = yield* ProviderService; return yield* provider.startSession(asThreadId("thread-claude-start"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-start"), cwd: "/tmp/project-claude-start", runtimeMode: "full-access", @@ -857,14 +1265,10 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* provider.listSessions(); }).pipe(Effect.provide(firstProviderLayer)); - const secondClaude = makeFakeCodexAdapter("claudeAgent"); - const secondRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "claudeAgent" - ? Effect.succeed(secondClaude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["claudeAgent"]), - }; + const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const secondRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, + }); const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); @@ -873,6 +1277,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); secondClaude.startSession.mockClear(); @@ -880,7 +1285,8 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.startSession(initial.threadId, { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: initial.threadId, cwd: "/tmp/project-claude-start", runtimeMode: "full-access", @@ -906,6 +1312,90 @@ routing.layer("ProviderServiceLive routing", (it) => { fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect( + "reuses persisted cwd when startSession resumes a claude session without cwd input", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + const firstClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const firstRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: firstClaude.adapter, + }); + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide(firstDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + const initial = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-claude-cwd"), { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + threadId: asThreadId("thread-claude-cwd"), + cwd: "/tmp/project-claude-cwd", + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(firstProviderLayer)); + + const secondClaude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); + const secondRegistry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("claudeAgent")]: secondClaude.adapter, + }); + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + ); + + secondClaude.startSession.mockClear(); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(initial.threadId, { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + threadId: initial.threadId, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(secondProviderLayer)); + + assert.equal(secondClaude.startSession.mock.calls.length, 1); + const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeAgent"); + assert.equal(startPayload.cwd, "/tmp/project-claude-cwd"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); const fanout = makeProviderServiceLayer(); @@ -914,7 +1404,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); @@ -923,20 +1414,20 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => Ref.update(eventsRef, (current) => [...current, event]), ).pipe(Effect.forkChild); - yield* sleep(50); + yield* advanceTestClock(50); const completedEvent: LegacyProviderRuntimeEvent = { type: "turn.completed", eventId: asEventId("evt-1"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", }; fanout.codex.emit(completedEvent); - yield* sleep(50); + yield* advanceTestClock(50); const events = yield* Ref.get(eventsRef); yield* Fiber.interrupt(consumer); @@ -945,6 +1436,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { events.some((entry) => entry.type === "turn.completed"), true, ); + assert.equal( + events.some( + (entry) => + entry.type === "turn.completed" && entry.providerInstanceId === codexInstanceId, + ), + true, + ); }), ); @@ -952,7 +1450,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-seq"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-seq"), runtimeMode: "full-access", }); @@ -962,13 +1461,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])), Effect.forkChild, ); - yield* sleep(50); + yield* advanceTestClock(50); fanout.codex.emit({ type: "tool.started", eventId: asEventId("evt-seq-1"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", @@ -977,8 +1476,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { fanout.codex.emit({ type: "tool.completed", eventId: asEventId("evt-seq-2"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", @@ -987,8 +1486,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { fanout.codex.emit({ type: "turn.completed", eventId: asEventId("evt-seq-3"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", @@ -1007,7 +1506,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); @@ -1026,14 +1526,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Stream.runForEach(() => Effect.fail("listener crash")), Effect.forkChild, ); - yield* sleep(50); + yield* advanceTestClock(50); const events: ReadonlyArray = [ { type: "tool.completed", eventId: asEventId("evt-ordered-1"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", @@ -1043,8 +1543,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { { type: "message.delta", eventId: asEventId("evt-ordered-2"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), delta: "hello", @@ -1052,8 +1552,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { { type: "turn.completed", eventId: asEventId("evt-ordered-3"), - provider: "codex", - createdAt: new Date().toISOString(), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", @@ -1079,7 +1579,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-metrics"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-metrics"), cwd: "/tmp/project", runtimeMode: "full-access", @@ -1108,7 +1609,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "interrupt", outcome: "success", }), @@ -1116,7 +1617,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "approval-response", outcome: "success", }), @@ -1124,7 +1625,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "user-input-response", outcome: "success", }), @@ -1132,7 +1633,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "rollback", outcome: "success", }), @@ -1140,7 +1641,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_sessions_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "stop", outcome: "success", }), @@ -1156,7 +1657,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-send-metrics"), { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-send-metrics"), cwd: "/tmp/project-send-metrics", runtimeMode: "full-access", @@ -1172,7 +1674,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turns_total", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "send", outcome: "success", }), @@ -1180,7 +1682,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); assert.equal( hasMetricSnapshot(snapshots, "t3_provider_turn_duration", { - provider: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), operation: "send", }), true, @@ -1191,6 +1693,50 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects session starts without an explicit provider instance id", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + validation.codex.startSession.mockClear(); + const failure = yield* Effect.flip( + provider.startSession(asThreadId("thread-missing-instance-id"), { + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-missing-instance-id"), + runtimeMode: "full-access", + }), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Provider instance id is required for provider 'codex'."); + assert.equal(validation.codex.startSession.mock.calls.length, 0); + }), + ); + + it.effect("rejects mismatched provider kind and provider instance id", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + validation.codex.startSession.mockClear(); + validation.claude.startSession.mockClear(); + const failure = yield* Effect.flip( + provider.startSession(asThreadId("thread-instance-mismatch"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: claudeAgentInstanceId, + threadId: asThreadId("thread-instance-mismatch"), + runtimeMode: "full-access", + }), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include( + failure.issue, + "Provider instance 'claudeAgent' belongs to driver 'claudeAgent', not 'codex'.", + ); + assert.equal(validation.codex.startSession.mock.calls.length, 0); + assert.equal(validation.claude.startSession.mock.calls.length, 0); + }), + ); + it.effect("returns ProviderValidationError for invalid input payloads", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -1223,9 +1769,9 @@ validation.layer("ProviderServiceLive validation", (it) => { validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => Effect.sync(() => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; return { - provider: "codex", + provider: ProviderDriverKind.make("codex"), status: "ready", threadId: input.threadId, runtimeMode: input.runtimeMode, @@ -1237,7 +1783,8 @@ validation.layer("ProviderServiceLive validation", (it) => { ); const session = yield* provider.startSession(asThreadId("thread-missing"), { - provider: "codex", + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, threadId: asThreadId("thread-missing"), cwd: "/tmp/project", runtimeMode: "full-access", diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 85fe9fbc326..2bce1f483b7 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -19,10 +19,21 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + type ProviderInstanceId, + type ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; -import { Effect, Layer, Option, PubSub, Schema, SchemaIssue, Stream } from "effect"; +import * as Cause from "effect/Cause"; +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 Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as Stream from "effect/Stream"; import { increment, @@ -34,19 +45,25 @@ import { providerTurnMetricAttributes, withMetrics, } from "../../observability/Metrics.ts"; -import { ProviderValidationError } from "../Errors.ts"; +import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, } from "../Services/ProviderSessionDirectory.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +const isModelSelection = Schema.is(ModelSelection); +/** + * Hook for tests that want to override the canonical event logger pulled + * from `ProviderEventLoggers`. Production wiring leaves this undefined and + * reads the logger off the tag. + */ export interface ProviderServiceLiveOptions { - readonly canonicalEventLogPath?: string; readonly canonicalEventLogger?: EventNdjsonLogger; } @@ -71,8 +88,9 @@ const decodeInputOrValidationError = (input: { readonly operation: string; readonly schema: S; readonly payload: unknown; -}) => - Schema.decodeUnknownEffect(input.schema)(input.payload).pipe( +}) => { + const decodeProviderRequestInput = Schema.decodeUnknownEffect(input.schema); + return decodeProviderRequestInput(input.payload).pipe( Effect.mapError( (schemaError) => new ProviderValidationError({ @@ -82,6 +100,7 @@ const decodeInputOrValidationError = (input: { }), ), ); +}; function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "stopped" | "error" { switch (session.status) { @@ -126,7 +145,7 @@ function readPersistedModelSelection( return undefined; } const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; - return Schema.is(ModelSelection)(raw) ? raw : undefined; + return isModelSelection(raw) ? raw : undefined; } function readPersistedCwd( @@ -141,32 +160,88 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +const dieOnMissingBindingInstanceId = ( + operation: string, + payload: { + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly provider?: ProviderDriverKind | undefined; + }, +): ProviderInstanceId => { + if (payload.providerInstanceId !== undefined) { + return payload.providerInstanceId; + } + throw new Error( + payload.provider + ? `${operation}: provider instance id is required for provider '${payload.provider}'.` + : `${operation}: provider instance id is required.`, + ); +}; + +const correlateRuntimeEventWithInstance = ( + source: { + readonly instanceId: ProviderInstanceId; + readonly provider: ProviderDriverKind; + }, + event: ProviderRuntimeEvent, +): ProviderRuntimeEvent => { + if (event.provider !== source.provider) { + throw new Error( + `ProviderService.streamEvents: provider instance '${source.instanceId}' is backed by driver '${source.provider}' but emitted driver '${event.provider}'.`, + ); + } + if (event.providerInstanceId !== undefined && event.providerInstanceId !== source.instanceId) { + throw new Error( + `ProviderService.streamEvents: provider instance '${source.instanceId}' emitted event for instance '${event.providerInstanceId}'.`, + ); + } + return { ...event, providerInstanceId: source.instanceId }; +}; + const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const analytics = yield* Effect.service(AnalyticsService); - const serverSettings = yield* ServerSettingsService; - const canonicalEventLogger = - options?.canonicalEventLogger ?? - (options?.canonicalEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.canonicalEventLogPath, { - stream: "canonical", - }) - : undefined); + const eventLoggers = yield* ProviderEventLoggers; + // Options-provided logger wins (test overrides); otherwise we take whatever + // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical + // log writer is attached", which downstream code already handles as a + // no-op. + const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => - canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, + canonicalEventLogger + ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) + : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), Effect.asVoid, ); + const requireBindingInstanceId = ( + operation: string, + payload: { + readonly providerInstanceId?: ProviderInstanceId | undefined; + readonly provider?: ProviderDriverKind | undefined; + }, + ): Effect.Effect => + payload.providerInstanceId !== undefined + ? Effect.succeed(payload.providerInstanceId) + : Effect.fail( + toValidationError( + operation, + payload.provider + ? `Provider instance id is required for provider '${payload.provider}'.` + : "Provider instance id is required.", + ), + ); + const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, @@ -176,38 +251,106 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly lastRuntimeEventAt?: string; }, ) => - directory.upsert({ - threadId, - provider: session.provider, - runtimeMode: session.runtimeMode, - status: toRuntimeStatus(session), - ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session, extra), + Effect.gen(function* () { + const providerInstanceId = yield* requireBindingInstanceId( + "ProviderService.upsertSessionBinding", + session, + ); + yield* directory.upsert({ + threadId, + provider: session.provider, + providerInstanceId, + runtimeMode: session.runtimeMode, + status: toRuntimeStatus(session), + ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), + runtimePayload: toRuntimePayloadFromSession(session, extra), + }); }); - const providers = yield* registry.listProviders(); - const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); - const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - increment(providerRuntimeEventsTotal, { - provider: event.provider, - eventType: event.type, - }).pipe(Effect.andThen(publishRuntimeEvent(event))); + const processRuntimeEvent = ( + source: { + readonly instanceId: ProviderInstanceId; + readonly provider: ProviderDriverKind; + }, + event: ProviderRuntimeEvent, + ): Effect.Effect => + Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( + Effect.flatMap((canonicalEvent) => + increment(providerRuntimeEventsTotal, { + provider: canonicalEvent.provider, + eventType: canonicalEvent.type, + }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + ), + ); + + // `subscribedAdapters` is our source-of-truth for "which instance adapters + // are currently wired into the runtime event bus". It both tracks the set + // of live subscriptions (so `reconcileInstanceSubscriptions` can diff and + // fork only the *new* or *rebuilt* ones) and serves as the dynamic adapter + // list consumed by `stopStaleSessionsForThread`, `listSessions`, and + // `runStopAll` — replacing the pre-Slice-D startup snapshot so hot-added + // instances become visible to those call sites as soon as settings edits + // land. + const subscribedAdapters = yield* Ref.make( + new Map>(), + ); + + const getAdapterEntries = Ref.get(subscribedAdapters).pipe( + Effect.map((map) => Array.from(map.entries())), + ); + + // Rebuild the map of id → adapter from the registry and fork a new event + // subscription for every instance that is either brand new or whose adapter + // identity changed (indicating the underlying `ProviderInstance` was torn + // down and rebuilt by `ProviderInstanceRegistry.reconcile`). Orphaned + // fibers for removed/replaced instances exit on their own because their + // adapter's `streamEvents` source terminates when the old scope closes. + const reconcileInstanceSubscriptions = Effect.gen(function* () { + const previous = yield* Ref.get(subscribedAdapters); + const currentIds = yield* registry.listInstances(); + const next = new Map>(); + for (const id of currentIds) { + const adapterOption = yield* registry + .getByInstance(id) + .pipe(Effect.tapError(Effect.logWarning), Effect.option); + if (Option.isNone(adapterOption)) continue; + const adapter = adapterOption.value; + next.set(id, adapter); + if (previous.get(id) !== adapter) { + yield* Stream.runForEach(adapter.streamEvents, (event) => + processRuntimeEvent( + { + instanceId: id, + provider: adapter.provider, + }, + event, + ), + ).pipe(Effect.forkScoped); + } + } + yield* Ref.set(subscribedAdapters, next); + }); - yield* Effect.forEach(adapters, (adapter) => - Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped), - ).pipe(Effect.asVoid); + const instanceChanges = yield* registry.subscribeChanges; + yield* reconcileInstanceSubscriptions; + yield* Stream.runForEach( + Stream.fromSubscription(instanceChanges), + () => reconcileInstanceSubscriptions, + ).pipe(Effect.forkScoped); const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { readonly binding: ProviderRuntimeBinding; readonly operation: string; }) { + const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding); yield* Effect.annotateCurrentSpan({ "provider.operation": "recover-session", "provider.kind": input.binding.provider, + "provider.instance_id": bindingInstanceId, "provider.thread_id": input.binding.threadId, }); return yield* Effect.gen(function* () { - const adapter = yield* registry.getByProvider(input.binding.provider); + const adapter = yield* registry.getByInstance(bindingInstanceId); const hasResumeCursor = input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); @@ -217,7 +360,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( (session) => session.threadId === input.binding.threadId, ); if (existing) { - yield* upsertSessionBinding(existing, input.binding.threadId); + yield* upsertSessionBinding( + { ...existing, providerInstanceId: bindingInstanceId }, + input.binding.threadId, + ); yield* analytics.record("provider.session.recovered", { provider: existing.provider, strategy: "adopt-existing", @@ -240,6 +386,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, + providerInstanceId: bindingInstanceId, ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), @@ -252,7 +399,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } - yield* upsertSessionBinding(resumed, input.binding.threadId); + yield* upsertSessionBinding( + { ...resumed, providerInstanceId: bindingInstanceId }, + input.binding.threadId, + ); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, strategy: "resume-thread", @@ -282,19 +432,73 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, ); } - const adapter = yield* registry.getByProvider(binding.provider); + const instanceId = yield* requireBindingInstanceId(input.operation, binding); + const adapter = yield* registry.getByInstance(instanceId); const hasRequestedSession = yield* adapter.hasSession(input.threadId); if (hasRequestedSession) { - return { adapter, threadId: input.threadId, isActive: true } as const; + return { + adapter, + instanceId, + threadId: input.threadId, + isActive: true, + } as const; } if (!input.allowRecovery) { - return { adapter, threadId: input.threadId, isActive: false } as const; + return { + adapter, + instanceId, + threadId: input.threadId, + isActive: false, + } as const; } - const recovered = yield* recoverSessionForThread({ binding, operation: input.operation }); - return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; + const recovered = yield* recoverSessionForThread({ + binding, + operation: input.operation, + }); + return { + adapter: recovered.adapter, + instanceId, + threadId: input.threadId, + isActive: true, + } as const; + }); + + const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { + readonly threadId: ThreadId; + readonly currentInstanceId: ProviderInstanceId; + }) { + const currentAdapters = yield* getAdapterEntries; + yield* Effect.forEach( + currentAdapters, + ([instanceId, adapter]) => + instanceId === input.currentInstanceId + ? Effect.void + : Effect.gen(function* () { + const hasSession = yield* adapter.hasSession(input.threadId); + if (!hasSession) { + return; + } + + yield* adapter.stopSession(input.threadId).pipe( + Effect.tap(() => + analytics.record("provider.session.stopped", { + provider: adapter.provider, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.stop-stale-failed", { + threadId: input.threadId, + provider: adapter.provider, + cause, + }), + ), + ); + }), + { discard: true }, + ); }); const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")( @@ -305,42 +509,73 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); - const input = { - ...parsed, - threadId, - provider: parsed.provider ?? "codex", - }; + const resolvedInstanceId = yield* requireBindingInstanceId( + "ProviderService.startSession", + parsed, + ); + let metricProvider = parsed.provider ?? String(resolvedInstanceId); yield* Effect.annotateCurrentSpan({ "provider.operation": "start-session", - "provider.kind": input.provider, + "provider.instance_id": resolvedInstanceId, "provider.thread_id": threadId, - "provider.runtime_mode": input.runtimeMode, + "provider.runtime_mode": parsed.runtimeMode, }); return yield* Effect.gen(function* () { - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError((error) => - toValidationError( - "ProviderService.startSession", - `Failed to load provider settings: ${error.message}`, - error, - ), - ), - ); - if (!settings.providers[input.provider].enabled) { + const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); + const resolvedProvider = instanceInfo.driverKind; + metricProvider = resolvedProvider; + if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { return yield* toValidationError( "ProviderService.startSession", - `Provider '${input.provider}' is disabled in T3 Code settings.`, + `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, + ); + } + const input = { + ...parsed, + threadId, + provider: resolvedProvider, + }; + if (!instanceInfo.enabled) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); const effectiveResumeCursor = input.resumeCursor ?? - (persistedBinding?.provider === input.provider + (persistedBinding?.providerInstanceId === resolvedInstanceId ? persistedBinding.resumeCursor : undefined); - const adapter = yield* registry.getByProvider(input.provider); + const effectiveCwd = + input.cwd ?? + (persistedBinding?.providerInstanceId === resolvedInstanceId + ? readPersistedCwd(persistedBinding.runtimePayload) + : undefined); + yield* Effect.annotateCurrentSpan({ + "provider.kind": resolvedProvider, + "provider.resume_cursor.source": + input.resumeCursor !== undefined + ? "request" + : effectiveResumeCursor !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId + ? "persisted" + : "none", + "provider.resume_cursor.present": effectiveResumeCursor !== undefined, + "provider.cwd.source": + input.cwd !== undefined + ? "request" + : effectiveCwd !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId + ? "persisted" + : "none", + "provider.cwd.effective": effectiveCwd ?? "", + }); + const adapter = yield* registry.getByInstance(resolvedInstanceId); const session = yield* adapter.startSession({ ...input, + providerInstanceId: resolvedInstanceId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), }); @@ -350,27 +585,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, ); } + const sessionWithInstance = { + ...session, + providerInstanceId: resolvedInstanceId, + }; - yield* upsertSessionBinding(session, threadId, { + yield* stopStaleSessionsForThread({ + threadId, + currentInstanceId: resolvedInstanceId, + }); + yield* upsertSessionBinding(sessionWithInstance, threadId, { modelSelection: input.modelSelection, }); yield* analytics.record("provider.session.started", { - provider: session.provider, + provider: sessionWithInstance.provider, runtimeMode: input.runtimeMode, - hasResumeCursor: session.resumeCursor !== undefined, - hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, + hasResumeCursor: sessionWithInstance.resumeCursor !== undefined, + hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, hasModel: typeof input.modelSelection?.model === "string" && input.modelSelection.model.trim().length > 0, }); - return session; + return sessionWithInstance; }).pipe( withMetrics({ counter: providerSessionsTotal, - attributes: providerMetricAttributes(input.provider, { - operation: "start", - }), + attributes: () => + providerMetricAttributes(metricProvider, { + operation: "start", + }), }), ); }, @@ -417,13 +661,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, status: "running", ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), activeTurnId: turn.turnId, lastRuntimeEvent: "provider.sendTurn", - lastRuntimeEventAt: new Date().toISOString(), + lastRuntimeEventAt: yield* nowIso, }, }); yield* analytics.record("provider.turn.sent", { @@ -582,7 +827,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } - yield* directory.remove(input.threadId); + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + }, + }); yield* analytics.record("provider.session.stopped", { provider: routed.adapter.provider, }); @@ -600,8 +853,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")( function* () { - const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => - adapter.listSessions(), + const currentAdapters = yield* getAdapterEntries; + const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => + adapter.listSessions().pipe( + Effect.map((sessions) => + sessions.map((session) => ({ + ...session, + providerInstanceId: instanceId, + })), + ), + ), ); const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); const persistedBindings = yield* directory.listThreadIds().pipe( @@ -625,29 +886,54 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } } - return activeSessions.map((session) => { + const sessions: ProviderSession[] = []; + for (const session of activeSessions) { const binding = bindingsByThreadId.get(session.threadId); if (!binding) { - return session; + sessions.push(session); + continue; } const overrides: { resumeCursor?: ProviderSession["resumeCursor"]; runtimeMode?: ProviderSession["runtimeMode"]; + providerInstanceId?: ProviderSession["providerInstanceId"]; } = {}; + overrides.providerInstanceId = dieOnMissingBindingInstanceId( + "ProviderService.listSessions", + binding, + ); + if (binding.provider !== session.provider) { + return yield* Effect.die( + new Error( + `ProviderService.listSessions: thread '${session.threadId}' is active on provider '${session.provider}' but persisted binding names provider '${binding.provider}'.`, + ), + ); + } + if (overrides.providerInstanceId !== session.providerInstanceId) { + return yield* Effect.die( + new Error( + `ProviderService.listSessions: thread '${session.threadId}' is active on provider instance '${session.providerInstanceId}' but persisted binding names '${overrides.providerInstanceId}'.`, + ), + ); + } if (session.resumeCursor === undefined && binding.resumeCursor !== undefined) { overrides.resumeCursor = binding.resumeCursor; } if (binding.runtimeMode !== undefined) { overrides.runtimeMode = binding.runtimeMode; } - return Object.assign({}, session, overrides); - }); + sessions.push(Object.assign({}, session, overrides)); + } + return sessions; }, ); - const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) => - registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities)); + const getCapabilities: ProviderServiceShape["getCapabilities"] = (instanceId) => + registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities)); + + const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) => + registry.getInstanceInfo(instanceId); const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn( "rollbackConversation", @@ -692,32 +978,46 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); - const activeSessions = yield* Effect.forEach(adapters, (adapter) => - adapter.listSessions(), + const currentAdapters = yield* getAdapterEntries; + const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => + adapter.listSessions().pipe( + Effect.map((sessions) => + sessions.map((session) => ({ + ...session, + providerInstanceId: instanceId, + })), + ), + ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => - upsertSessionBinding(session, session.threadId, { - lastRuntimeEvent: "provider.stopAll", - lastRuntimeEventAt: new Date().toISOString(), - }), - ).pipe(Effect.asVoid); - yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); - yield* Effect.forEach(threadIds, (threadId) => - directory.getProvider(threadId).pipe( - Effect.flatMap((provider) => - directory.upsert({ - threadId, - provider, - status: "stopped", - runtimePayload: { - activeTurnId: null, - lastRuntimeEvent: "provider.stopAll", - lastRuntimeEventAt: new Date().toISOString(), - }, - }), - ), + Effect.flatMap(nowIso, (lastRuntimeEventAt) => + upsertSessionBinding(session, session.threadId, { + lastRuntimeEvent: "provider.stopAll", + lastRuntimeEventAt, + }), ), ).pipe(Effect.asVoid); + yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); + const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach(bindings, (binding) => + Effect.gen(function* () { + const providerInstanceId = dieOnMissingBindingInstanceId( + "ProviderService.stopAll", + binding, + ); + return yield* directory.upsert({ + threadId: binding.threadId, + provider: binding.provider, + providerInstanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + lastRuntimeEvent: "provider.stopAll", + lastRuntimeEventAt: yield* nowIso, + }, + }); + }), + ).pipe(Effect.asVoid); yield* analytics.record("provider.sessions.stopped_all", { sessionCount: threadIds.length, }); @@ -725,8 +1025,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); yield* Effect.addFinalizer(() => - Effect.catch(runStopAll(), (cause) => - Effect.logWarning("failed to stop provider service", { cause }), + runStopAll().pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }), + ), ), ); @@ -739,6 +1041,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( stopSession, listSessions, getCapabilities, + getInstanceInfo, rollbackConversation, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 3ffd6941ade..f9793ca9d1f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -1,12 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; -import { assertFailure, assertSome } from "@effect/vitest/utils"; -import { Effect, Layer, Option } from "effect"; +import { assertSome } from "@effect/vitest/utils"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { @@ -15,7 +18,6 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; @@ -31,7 +33,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts, reads, and removes thread bindings", () => + it("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; @@ -39,7 +41,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const initialThreadId = ThreadId.make("thread-1"); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: initialThreadId, }); @@ -48,7 +50,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const resolvedBinding = yield* directory.getBinding(initialThreadId); assertSome(resolvedBinding, { threadId: initialThreadId, - provider: "codex", + provider: ProviderDriverKind.make("codex"), }); if (Option.isSome(resolvedBinding)) { assert.equal(resolvedBinding.value.threadId, initialThreadId); @@ -57,7 +59,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const nextThreadId = ThreadId.make("thread-2"); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId: nextThreadId, }); const updatedBinding = yield* directory.getBinding(nextThreadId); @@ -76,16 +78,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const threadIds = yield* directory.listThreadIds(); assert.deepEqual(threadIds, [nextThreadId]); - - yield* directory.remove(nextThreadId); - const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result); - assertFailure( - missingProvider, - new ProviderSessionDirectoryPersistenceError({ - operation: "ProviderSessionDirectory.getProvider", - detail: `No persisted provider binding found for thread '${nextThreadId}'.`, - }), - ); })); it("persists runtime fields and merges payload updates", () => @@ -96,7 +88,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const threadId = ThreadId.make("thread-runtime"); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, status: "starting", resumeCursor: { @@ -109,7 +101,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, status: "running", runtimePayload: { @@ -133,6 +125,80 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("lists persisted bindings with metadata in oldest-first order", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const olderThreadId = ThreadId.make("thread-runtime-older"); + const newerThreadId = ThreadId.make("thread-runtime-newer"); + + yield* runtimeRepository.upsert({ + threadId: newerThreadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }); + + yield* runtimeRepository.upsert({ + threadId: olderThreadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }); + + const bindings = yield* directory.listBindings(); + + assert.deepEqual(bindings, [ + { + threadId: olderThreadId, + provider: ProviderDriverKind.make("claudeAgent"), + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }, + { + threadId: newerThreadId, + provider: ProviderDriverKind.make("codex"), + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }, + ]); + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; @@ -142,16 +208,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* runtimeRepository.upsert({ threadId, providerName: "claudeAgent", + providerInstanceId: null, adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", - lastSeenAt: new Date().toISOString(), + lastSeenAt: "2026-01-01T00:00:00.000Z", resumeCursor: null, runtimePayload: null, }); yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, }); @@ -174,7 +241,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - provider: "codex", + provider: ProviderDriverKind.make("codex"), threadId, }); }).pipe(Effect.provide(directoryLayer)); @@ -188,7 +255,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const resolvedBinding = yield* directory.getBinding(threadId); assertSome(resolvedBinding, { threadId, - provider: "codex", + provider: ProviderDriverKind.make("codex"), }); if (Option.isSome(resolvedBinding)) { assert.equal(resolvedBinding.value.threadId, threadId); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d6961..0508f6c8cb3 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,13 +1,20 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; - +import { defaultInstanceIdForDriver, ProviderDriverKind, type ThreadId } 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 Schema from "effect/Schema"; + +import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, + type ProviderRuntimeBindingWithMetadata, type ProviderSessionDirectoryShape, } from "../Services/ProviderSessionDirectory.ts"; +const decodeProviderDriverKindValue = Schema.decodeUnknownEffect(ProviderDriverKind); function toPersistenceError(operation: string) { return (cause: unknown) => @@ -18,18 +25,19 @@ function toPersistenceError(operation: string) { }); } -function decodeProviderKind( +function decodeProviderDriverKind( providerName: string, operation: string, -): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { - return Effect.succeed(providerName); - } - return Effect.fail( - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Unknown persisted provider '${providerName}'.`, - }), +): Effect.Effect { + return decodeProviderDriverKindValue(providerName).pipe( + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + cause, + }), + ), ); } @@ -50,6 +58,32 @@ function mergeRuntimePayload( return next; } +function toRuntimeBinding( + runtime: ProviderSessionRuntime, + operation: string, +): Effect.Effect { + return decodeProviderDriverKind(runtime.providerName, operation).pipe( + Effect.map( + (provider) => + ({ + threadId: runtime.threadId, + provider, + // Migration boundary only: rows written before the instance split + // have a null provider_instance_id. Promote them as they leave + // persistence so hot routing code never has to infer an instance + // from a driver kind. + providerInstanceId: runtime.providerInstanceId ?? defaultInstanceIdForDriver(provider), + adapterKey: runtime.adapterKey, + runtimeMode: runtime.runtimeMode, + status: runtime.status, + resumeCursor: runtime.resumeCursor, + runtimePayload: runtime.runtimePayload, + lastSeenAt: runtime.lastSeenAt, + }) satisfies ProviderRuntimeBindingWithMetadata, + ), + ); +} + const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; @@ -60,18 +94,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((provider) => - Option.some({ - threadId: value.threadId, - provider, - adapterKey: value.adapterKey, - runtimeMode: value.runtimeMode, - status: value.status, - resumeCursor: value.resumeCursor, - runtimePayload: value.runtimePayload, - }), - ), + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( + Effect.map((binding) => Option.some(binding)), ), }), ), @@ -91,13 +115,22 @@ const makeProviderSessionDirectory = Effect.gen(function* () { }); } - const now = new Date().toISOString(); + const now = DateTime.formatIso(yield* DateTime.now); const providerChanged = existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; + const providerInstanceId = + binding.providerInstanceId ?? (!providerChanged ? existingRuntime?.providerInstanceId : null); + if (providerInstanceId === null || providerInstanceId === undefined) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "providerInstanceId is required for provider session runtime bindings.", + }); + } yield* repository .upsert({ threadId: resolvedThreadId, providerName: binding.provider, + providerInstanceId, adapterKey: binding.adapterKey ?? (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), @@ -132,25 +165,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => - repository - .deleteByThreadId({ threadId }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), - ); - const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), Effect.map((rows) => rows.map((row) => row.threadId)), ); + const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => + repository.list().pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), + { concurrency: "unbounded" }, + ), + ), + ); + return { upsert, getProvider, getBinding, - remove, listThreadIds, + listBindings, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts new file mode 100644 index 00000000000..6bd90e283e6 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -0,0 +1,577 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProjectId, + ThreadId, + TurnId, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; +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 Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import { ProviderValidationError } from "../Errors.ts"; +import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; +import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; +import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; + +const defaultModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +} as const; + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 2_000, +): Promise { + const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; + const poll = async (): Promise => { + if (await predicate()) { + return; + } + if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) { + throw new Error("Timed out waiting for expectation."); + } + await Effect.runPromise(Effect.yieldNow); + return poll(); + }; + + return poll(); +} + +const drainFibers = Effect.forEach(Array.from({ length: 10 }), () => Effect.yieldNow, { + discard: true, +}); + +const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + +function makeReadModel( + threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly session: { + readonly threadId: ThreadId; + readonly status: "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"; + readonly providerName: "codex" | "claudeAgent"; + readonly runtimeMode: "approval-required" | "full-access" | "auto-accept-edits"; + readonly activeTurnId: TurnId | null; + readonly lastError: string | null; + readonly updatedAt: string; + } | null; + }>, +) { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-provider-session-reaper"); + + return { + snapshotSequence: 0, + updatedAt: now, + projects: [ + { + id: projectId, + title: "Provider Reaper Project", + workspaceRoot: "/tmp/provider-reaper-project", + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: threads.map((thread) => ({ + id: thread.id, + projectId, + title: `Thread ${thread.id}`, + modelSelection: defaultModelSelection, + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + latestTurn: null, + messages: [], + session: thread.session, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + })), + }; +} + +describe("ProviderSessionReaper", () => { + let runtime: ManagedRuntime.ManagedRuntime< + ProviderSessionReaper | ProviderSessionRuntimeRepository, + unknown + > | null = null; + let scope: Scope.Closeable | null = null; + + afterEach(async () => { + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + scope = null; + if (runtime) { + await runtime.dispose(); + } + runtime = null; + }); + + async function createHarness(input: { + readonly readModel: ReturnType; + readonly stopSessionImplementation?: (input: { + readonly threadId: ThreadId; + }) => ReturnType; + }) { + const stoppedThreadIds = new Set(); + const stopSession = vi.fn( + (request) => + (input.stopSessionImplementation + ? input.stopSessionImplementation(request) + : Effect.sync(() => { + stoppedThreadIds.add(request.threadId); + })) as ReturnType, + ); + + const providerService: ProviderServiceShape = { + startSession: () => unsupported(), + sendTurn: () => unsupported(), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession, + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => { + const driverKind = ProviderDriverKind.make(String(instanceId)); + return Effect.succeed({ + instanceId, + driverKind, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind, + continuationKey: `${driverKind}:instance:${instanceId}`, + }, + }); + }, + rollbackConversation: () => unsupported(), + streamEvents: Stream.empty, + }; + + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const layer = makeProviderSessionReaperLive({ + inactivityThresholdMs: 1_000, + sweepIntervalMs: 60_000, + }).pipe( + Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(runtimeRepositoryLayer), + Layer.provideMerge(Layer.succeed(ProviderService, providerService)), + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => + Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: (threadId) => + Effect.succeed( + input.readModel.threads.find((thread) => thread.id === threadId) + ? Option.some(input.readModel.threads.find((thread) => thread.id === threadId)!) + : Option.none(), + ), + getThreadDetailById: () => Effect.die("unused"), + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + runtime = ManagedRuntime.make(layer); + return { stopSession, stoppedThreadIds }; + } + + it("reaps stale persisted sessions without active turns", async () => { + const threadId = ThreadId.make("thread-reaper-stale"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stale", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 1); + + expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ threadId }); + expect(harness.stoppedThreadIds.has(threadId)).toBe(true); + }); + + it("skips stale sessions when the thread still has an active turn", async () => { + const threadId = ThreadId.make("thread-reaper-active-turn"); + const turnId = TurnId.make("turn-reaper-active"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "running", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-active-turn", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await Effect.runPromise(drainFibers); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("does not reap sessions that are still within the inactivity threshold", async () => { + const threadId = ThreadId.make("thread-reaper-fresh"); + const now = DateTime.formatIso(await Effect.runPromise(DateTime.now)); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: now, + resumeCursor: { + opaque: "resume-fresh", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await Effect.runPromise(drainFibers); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("skips persisted sessions that are already marked stopped", async () => { + const threadId = ThreadId.make("thread-reaper-stopped"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stopped", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await Effect.runPromise(drainFibers); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("continues reaping other sessions when one stop attempt fails", async () => { + const failedThreadId = ThreadId.make("thread-reaper-stop-failure"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-success"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: failedThreadId, + session: { + threadId: failedThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === failedThreadId + ? Effect.fail( + new ProviderValidationError({ + operation: "ProviderSessionReaper.test", + issue: "simulated stop failure", + }), + ) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: failedThreadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-failure", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-success", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + failedThreadId, + reapedThreadId, + ]); + }); + + it("continues reaping other sessions when one stop attempt defects", async () => { + const defectThreadId = ThreadId.make("thread-reaper-stop-defect"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-after-defect"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: defectThreadId, + session: { + threadId: defectThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === defectThreadId + ? Effect.die(new Error("simulated stop defect")) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: defectThreadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-defect", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-after-defect", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + defectThreadId, + reapedThreadId, + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts new file mode 100644 index 00000000000..ca396b40596 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -0,0 +1,138 @@ +import * as Clock from "effect/Clock"; +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 Schedule from "effect/Schedule"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { + ProviderSessionReaper, + type ProviderSessionReaperShape, +} from "../Services/ProviderSessionReaper.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; + +const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; +const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; + +export interface ProviderSessionReaperLiveOptions { + readonly inactivityThresholdMs?: number; + readonly sweepIntervalMs?: number; +} + +const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) => + Effect.gen(function* () { + const providerService = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const inactivityThresholdMs = Math.max( + 1, + options?.inactivityThresholdMs ?? DEFAULT_INACTIVITY_THRESHOLD_MS, + ); + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + + const sweep = Effect.gen(function* () { + const bindings = yield* directory.listBindings(); + const now = yield* Clock.currentTimeMillis; + let reapedCount = 0; + + for (const binding of bindings) { + if (binding.status === "stopped") { + continue; + } + + const lastSeenMs = Date.parse(binding.lastSeenAt); + if (Number.isNaN(lastSeenMs)) { + yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { + threadId: binding.threadId, + provider: binding.provider, + lastSeenAt: binding.lastSeenAt, + }); + continue; + } + + const idleDurationMs = now - lastSeenMs; + if (idleDurationMs < inactivityThresholdMs) { + continue; + } + + const thread = yield* projectionSnapshotQuery + .getThreadShellById(binding.threadId) + .pipe(Effect.map(Option.getOrUndefined)); + if (thread?.session?.activeTurnId != null) { + yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", { + threadId: binding.threadId, + activeTurnId: thread.session.activeTurnId, + idleDurationMs, + }); + continue; + } + + const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( + Effect.tap(() => + Effect.logInfo("provider.session.reaped", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + reason: "inactivity_threshold", + }), + ), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.reaper.stop-failed", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (reaped) { + reapedCount += 1; + } + } + + if (reapedCount > 0) { + yield* Effect.logInfo("provider.session.reaper.sweep-complete", { + reapedCount, + totalBindings: bindings.length, + }); + } + }); + + const start: ProviderSessionReaperShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep.pipe( + Effect.catch((error: unknown) => + Effect.logWarning("provider.session.reaper.sweep-failed", { + error, + }), + ), + Effect.catchDefect((defect: unknown) => + Effect.logWarning("provider.session.reaper.sweep-defect", { + defect, + }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("provider.session.reaper.started", { + inactivityThresholdMs, + sweepIntervalMs, + }); + }); + + return { + start, + } satisfies ProviderSessionReaperShape; + }); + +export const makeProviderSessionReaperLive = (options?: ProviderSessionReaperLiveOptions) => + Layer.effect(ProviderSessionReaper, makeProviderSessionReaper(options)); + +export const ProviderSessionReaperLive = makeProviderSessionReaperLive(); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts new file mode 100644 index 00000000000..3a57f374de4 --- /dev/null +++ b/apps/server/src/provider/ProviderDriver.ts @@ -0,0 +1,169 @@ +/** + * ProviderDriver / ProviderInstance — driver SPI as plain values. + * + * `ProviderDriver` is a record, not a Context.Service. The thing it produces + * (`ProviderInstance`) is also a record — three captured closures + * (`snapshot`, `adapter`, `textGeneration`), an id, and a driver kind. There + * are intentionally no per-driver Context tags because tags are + * singleton-per-runtime and we need many instances of the same driver. + * + * The only Effect service involved is `ProviderInstanceRegistry`, which + * owns the live `Map` and is itself a + * singleton. + * + * Driver factories are functions of `(typed config, env)` where: + * - `typed config` is decoded once by the registry via `configSchema`, + * so drivers never deal with raw `unknown`. + * - `env` flows through Effect's R channel. Each driver declares the + * subset of infrastructure services it needs (FileSystem, + * ChildProcessSpawner, …) on its `create` return type; the registry + * layer's R is the union of those, and the runtime layer satisfies it. + * + * @module provider/ProviderDriver + */ +import type { + ProviderDriverKind, + ProviderInstanceEnvironment, + ProviderInstanceId, +} from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; +import type * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; +import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; +import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; + +/** + * Static metadata advertised by a driver. Used for default presentation + * and (later) settings UI. Doesn't need to be Effect-typed because nothing + * about it is dynamic — drivers are registered at startup. + */ +export interface ProviderDriverMetadata { + /** Human-readable name for the driver itself (e.g. "Codex"). */ + readonly displayName: string; + /** + * Whether the driver may be instantiated more than once concurrently. + * Defaults to `true`. Set to `false` for drivers that wrap a global + * resource (e.g. a single desktop app socket) — the registry then + * rejects multi-instance configurations with a clear error. + */ + readonly supportsMultipleInstances?: boolean; +} + +/** + * One materialized provider instance. Held by the registry, looked up by + * `instanceId`, torn down by closing the scope it was created in. + * + * The three "shape" fields are captured closures owned by this instance — + * stopping one instance cannot affect another, and starting a second + * instance of the same driver does not reach into the first instance's + * state. + */ +export interface ProviderInstance { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly continuationIdentity: ProviderContinuationIdentity; + readonly displayName: string | undefined; + readonly accentColor?: string | undefined; + readonly enabled: boolean; + readonly snapshot: ServerProviderShape; + readonly adapter: ProviderAdapterShape; + readonly textGeneration: TextGenerationShape; +} + +export interface ProviderContinuationIdentity { + readonly driverKind: ProviderDriverKind; + readonly continuationKey: string; +} + +export function defaultProviderContinuationIdentity(input: { + readonly driverKind: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; +}): ProviderContinuationIdentity { + return { + driverKind: input.driverKind, + continuationKey: `${input.driverKind}:instance:${input.instanceId}`, + }; +} + +/** + * Inputs the registry passes to a driver's `create` function. + * + * `config` is the typed payload — already decoded by the registry through + * `driver.configSchema`. Drivers never decode their own raw envelope. + */ +export interface ProviderDriverCreateInput { + readonly instanceId: ProviderInstanceId; + readonly displayName: string | undefined; + readonly accentColor?: string | undefined; + readonly environment: ProviderInstanceEnvironment; + readonly enabled: boolean; + readonly config: Config; +} + +/** + * Driver SPI — registered as a plain value, not a Layer. + * + * `Config` is whatever the driver decoded from + * `ProviderInstanceConfig.config`. `R` is the union of infrastructure + * services the driver depends on; the registry layer aggregates `R` across + * all registered drivers and the runtime supplies them. + * + * `create` is responsible for *all* per-instance state — process handles, + * pubsub topics, refs, file watchers — and must release them when its + * scope closes. Two calls to `create` with different `instanceId` / + * `config` MUST yield instances with no shared mutable state. + */ +export interface ProviderDriver { + readonly driverKind: ProviderDriverKind; + readonly metadata: ProviderDriverMetadata; + /** + * Decoder for the opaque `ProviderInstanceConfig.config` envelope. The + * registry runs this exactly once per (re)load of an instance; a decode + * failure is surfaced as `ProviderDriverError` and downgraded to an + * unavailable shadow snapshot. + * + * The `Encoded` parameter is intentionally left as `unknown` (not + * `Config`) so schemas with `withDecodingDefault` / transformations — where + * the encoded shape differs from the decoded shape — satisfy the SPI + * without casts. The registry only ever decodes `unknown` envelopes here, + * so the precise encoded type is irrelevant at this boundary. + * + * Using `Codec` rather than `Schema` pins `DecodingServices = never` — if + * we used `Schema`, the erased `any` in `AnyProviderDriver` would + * widen `DecodingServices` to `unknown` and poison the R channel of every + * caller of `decodeUnknownEffect`. + */ + readonly configSchema: Schema.Codec; + /** + * Default config payload used when the legacy + * `ServerSettings.providers.` entry is empty or when the driver + * is auto-bootstrapped without user configuration. Returning a typed + * default keeps the migration path simple — no special-casing needed + * to construct a "blank" instance. + */ + readonly defaultConfig: () => Config; + /** + * Materialize one instance. The returned effect runs in a scope owned + * by the registry; closing that scope releases every resource the + * driver opened. Failures become unavailable shadow snapshots — the + * driver MUST NOT throw defects. + */ + readonly create: ( + input: ProviderDriverCreateInput, + ) => Effect.Effect; +} + +/** + * Heterogeneous-array convenience: the registry stores drivers as + * `ReadonlyArray>` where `R` is the union of all + * registered drivers' env requirements. + */ +// `any` here intentionally erases the per-driver Config; the registry +// already decoded it before invoking `create`, so downstream code never +// needs the original `Config` type. Using `unknown` instead would force +// `create` callers into casts since `unknown` is not assignable to a +// concrete `Config` from inside the driver body. +export type AnyProviderDriver = ProviderDriver; diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts new file mode 100644 index 00000000000..f37b328b150 --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; + +describe("mergeProviderInstanceEnvironment", () => { + it("overrides inherited environment values and preserves empty strings", () => { + expect( + mergeProviderInstanceEnvironment( + [ + { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, + { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, + ], + { ANTHROPIC_API_KEY: "inherited", PATH: "/bin" }, + ), + ).toMatchObject({ + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "", + PATH: "/bin", + }); + }); +}); diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts new file mode 100644 index 00000000000..e469253604e --- /dev/null +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -0,0 +1,16 @@ +import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; + +export function mergeProviderInstanceEnvironment( + environment: ProviderInstanceEnvironment | undefined, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + if (!environment || environment.length === 0) { + return baseEnv; + } + + const next: NodeJS.ProcessEnv = { ...baseEnv }; + for (const variable of environment) { + next[variable.name] = variable.value; + } + return next; +} diff --git a/apps/server/src/provider/Services/ClaudeAdapter.ts b/apps/server/src/provider/Services/ClaudeAdapter.ts index e8c33bd8e40..ed9bd7081bc 100644 --- a/apps/server/src/provider/Services/ClaudeAdapter.ts +++ b/apps/server/src/provider/Services/ClaudeAdapter.ts @@ -1,30 +1,19 @@ /** - * ClaudeAdapter - Claude Agent implementation of the generic provider adapter contract. + * ClaudeAdapter — shape type for the Claude provider adapter. * - * This service owns Claude runtime/session semantics and emits canonical - * provider runtime events. It does not perform cross-provider routing, shared - * event fan-out, or checkpoint orchestration. - * - * Uses Effect `Context.Service` for dependency injection and returns the - * shared provider-adapter error channel with `provider: "claudeAgent"` context. + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/ClaudeDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. * * @module ClaudeAdapter */ -import { Context } from "effect"; - import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; /** - * ClaudeAdapterShape - Service API for the Claude Agent provider adapter. - */ -export interface ClaudeAdapterShape extends ProviderAdapterShape { - readonly provider: "claudeAgent"; -} - -/** - * ClaudeAdapter - Service tag for Claude Agent provider adapter operations. + * ClaudeAdapterShape — per-instance Claude adapter contract. Carries + * a branded driver kind as the nominal discriminant. */ -export class ClaudeAdapter extends Context.Service()( - "t3/provider/Services/ClaudeAdapter", -) {} +export interface ClaudeAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts deleted file mode 100644 index 7f90c549c63..00000000000 --- a/apps/server/src/provider/Services/ClaudeProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "effect"; - -import type { ServerProviderShape } from "./ServerProvider"; - -export interface ClaudeProviderShape extends ServerProviderShape {} - -export class ClaudeProvider extends Context.Service()( - "t3/provider/Services/ClaudeProvider", -) {} diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts index e7a5508c9c7..33fe0fa12be 100644 --- a/apps/server/src/provider/Services/CodexAdapter.ts +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -1,30 +1,19 @@ /** - * CodexAdapter - Codex implementation of the generic provider adapter contract. + * CodexAdapter — shape type for the Codex provider adapter. * - * This service owns Codex app-server process / JSON-RPC semantics and emits - * Codex provider events. It does not perform cross-provider routing, shared - * event fan-out, or checkpoint orchestration. - * - * Uses Effect `Context.Service` for dependency injection and returns the - * shared provider-adapter error channel with `provider: "codex"` context. + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/CodexDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. * * @module CodexAdapter */ -import { Context } from "effect"; - import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; /** - * CodexAdapterShape - Service API for the Codex provider adapter. - */ -export interface CodexAdapterShape extends ProviderAdapterShape { - readonly provider: "codex"; -} - -/** - * CodexAdapter - Service tag for Codex provider adapter operations. + * CodexAdapterShape — per-instance Codex adapter contract. Carries + * a branded driver kind as the nominal discriminant. */ -export class CodexAdapter extends Context.Service()( - "t3/provider/Services/CodexAdapter", -) {} +export interface CodexAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts deleted file mode 100644 index 6820d4cb4f9..00000000000 --- a/apps/server/src/provider/Services/CodexProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "effect"; - -import type { ServerProviderShape } from "./ServerProvider"; - -export interface CodexProviderShape extends ServerProviderShape {} - -export class CodexProvider extends Context.Service()( - "t3/provider/Services/CodexProvider", -) {} diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 00000000000..83581f0a454 --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -0,0 +1,19 @@ +/** + * CursorAdapter — shape type for the Cursor provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/CursorDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module CursorAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * CursorAdapterShape — per-instance Cursor adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface CursorAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/OpenCodeAdapter.ts b/apps/server/src/provider/Services/OpenCodeAdapter.ts new file mode 100644 index 00000000000..e3ad97904d1 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeAdapter.ts @@ -0,0 +1,19 @@ +/** + * OpenCodeAdapter — shape type for the OpenCode provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/OpenCodeDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module OpenCodeAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * OpenCodeAdapterShape — per-instance OpenCode adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface OpenCodeAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 38a05f75748..01eeae7b7bd 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -10,7 +10,7 @@ import type { ApprovalRequestId, ProviderApprovalDecision, - ProviderKind, + ProviderDriverKind, ProviderUserInputAnswers, ProviderRuntimeEvent, ProviderSendTurnInput, @@ -20,10 +20,10 @@ import type { ProviderTurnStartResult, TurnId, } from "@t3tools/contracts"; -import type { Effect } from "effect"; -import type { Stream } from "effect"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; -export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported"; +export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; export interface ProviderAdapterCapabilities { /** @@ -46,7 +46,7 @@ export interface ProviderAdapterShape { /** * Provider kind implemented by this adapter. */ - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; readonly capabilities: ProviderAdapterCapabilities; /** diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts index b8e9d4b21c3..5b755c42eed 100644 --- a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -1,34 +1,94 @@ /** * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. * - * Maps a provider kind to the concrete adapter service (Codex, Claude, etc). - * It does not own session lifecycle or routing rules; `ProviderService` uses - * this registry together with `ProviderSessionDirectory`. + * Maps a `ProviderInstanceId` (the new per-instance routing key) or a + * `ProviderDriverKind` (legacy single-instance-per-driver key) to the concrete + * adapter service (Codex, Claude, etc). It does not own session lifecycle + * or routing rules; `ProviderService` uses this registry together with + * `ProviderSessionDirectory`. + * + * During the driver/instance migration this tag exposes both flavours: + * + * - `getByInstance` / `listInstances` — new per-instance routing. Callers + * that already know an `instanceId` (threads, sessions, events) + * should prefer these. + * (`defaultInstanceIdForDriver(kind) === kind`), matching the pre-Slice-D + * behaviour. New code should not grow additional callers of the kind-keyed + * methods; they exist so the settings UI, WS refresh RPC, and a handful + * of legacy persisted rows can still be routed during the rollout. * * @module ProviderAdapterRegistry */ -import type { ProviderKind } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; +import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import type * as Stream from "effect/Stream"; import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; +import type { ProviderContinuationIdentity } from "../ProviderDriver.ts"; + +export interface ProviderInstanceRoutingInfo { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly displayName: string | undefined; + readonly accentColor?: string | undefined; + readonly enabled: boolean; + readonly continuationIdentity: ProviderContinuationIdentity; +} /** - * ProviderAdapterRegistryShape - Service API for adapter lookup by provider kind. + * ProviderAdapterRegistryShape - Service API for adapter lookup. */ export interface ProviderAdapterRegistryShape { /** - * Resolve the adapter for a provider kind. + * Resolve the adapter for a specific instance id. Returns + * `ProviderUnsupportedError` if no such instance is currently registered + * (which covers "never configured" *and* "configured but the driver is + * unavailable in this build" — both surface the same failure to callers + * that expect a working adapter). */ - readonly getByProvider: ( - provider: ProviderKind, + readonly getByInstance: ( + instanceId: ProviderInstanceId, ) => Effect.Effect, ProviderUnsupportedError>; + readonly getInstanceInfo: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** + * List all live instance ids. Excludes unavailable/shadow instances — + * callers of this method want something they can pass to `getByInstance`. + */ + readonly listInstances: () => Effect.Effect>; + /** - * List provider kinds currently registered. + * Legacy: list provider kinds whose default instance is currently + * registered. + * + * @deprecated Prefer `listInstances`. Retained for migration-era call + * sites that iterate providers to build UI/metrics. */ - readonly listProviders: () => Effect.Effect>; + readonly listProviders: () => Effect.Effect>; + + /** + * Change notification stream mirroring `ProviderInstanceRegistry.streamChanges`. + * Emits one `void` tick whenever the set of live instances changes + * (instance added, removed, or rebuilt after a settings edit). Consumers + * that fan out `adapter.streamEvents` per instance — e.g. `ProviderService`'s + * runtime event bus — re-pull `listInstances` on each tick and fork new + * subscriptions for instances they haven't seen yet. + */ + readonly streamChanges: Stream.Stream; + + /** + * Acquire a change subscription synchronously in the caller's current fiber. + * Consumers that must avoid missing a publish between initial reconciliation + * and watcher startup should use this, then fork `Stream.fromSubscription`. + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } /** @@ -38,5 +98,3 @@ export class ProviderAdapterRegistry extends Context.Service< ProviderAdapterRegistry, ProviderAdapterRegistryShape >()("t3/provider/Services/ProviderAdapterRegistry") {} - -// Dummy comment for workflow testing. diff --git a/apps/server/src/provider/Services/ProviderInstanceRegistry.ts b/apps/server/src/provider/Services/ProviderInstanceRegistry.ts new file mode 100644 index 00000000000..cfea1142666 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderInstanceRegistry.ts @@ -0,0 +1,87 @@ +/** + * ProviderInstanceRegistry — the single Effect service in the new model. + * + * Owns a `Map` produced by running + * registered driver factories against `ServerSettings.providerInstances`. + * The registry watches settings; when an instance's config changes (or + * the entry disappears), the registry tears down the affected instance's + * scope and rebuilds — that's the entire hot-reload story. + * + * What rest-of-server reads from here: + * - `getInstance(instanceId)` — for routing turn/session calls. + * - `listInstances` — for snapshot aggregation in `ProviderRegistry`. + * - `listUnavailable` — `ServerProvider` shadows for instances whose + * driver is not registered in this build (rollback / fork tolerance). + * - `streamChanges` — coalesced "registry mutated" pings so consumers + * can re-pull lists or re-broadcast. + * + * @module provider/Services/ProviderInstanceRegistry + */ +import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import type * as Stream from "effect/Stream"; + +import type { ProviderInstance } from "../ProviderDriver.ts"; + +export interface ProviderInstanceRegistryShape { + /** + * Look up one instance by id. Returns `undefined` (not Option) when the + * id is unknown — callers branch on falsy and emit + * `ProviderInstanceNotFoundError`. + */ + readonly getInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + /** + * Every available (driver-registered, successfully created) instance, + * in stable settings-author order. + */ + readonly listInstances: Effect.Effect>; + /** + * Wire-shape shadow snapshots for instances whose driver is unknown to + * this build (or whose config failed to decode). Suitable for merging + * directly into `ProviderRegistry` output. + */ + readonly listUnavailable: Effect.Effect>; + /** + * Push notification stream emitted whenever the registry's contents + * change — instance added, removed, or rebuilt. The payload is `void` + * because consumers always want to re-pull `listInstances` / + * `listUnavailable` together. + * + * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the + * stream starts running, forking a consumer via + * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next + * publish — the forked fiber may not have subscribed yet when the + * publish lands. Hot-reload consumers that must not miss a publish + * should use `subscribeChanges` below instead, which acquires the + * subscription synchronously in the caller's fiber before the consumer + * loop is forked. + */ + readonly streamChanges: Stream.Stream; + /** + * Acquire a subscription to the registry's change channel synchronously + * in the caller's fiber. Returns a `PubSub.Subscription` whose + * lifetime is scoped to the provided `Scope` (the subscription is + * released when the scope closes). Consumers typically `yield*` this + * in the same fiber that forks their consumer loop, then drain with + * `PubSub.take(subscription)` inside `Effect.forever`. Because the + * subscription is registered with the PubSub before this `yield*` + * returns, no subsequent publish can land in a gap. + * + * This exists because the `ProviderInstanceRegistry` publishes on a + * PubSub and `Stream.fromPubSub` defers subscription until the stream + * starts executing — a consumer that `forkScoped`s the stream + * consumption can miss a publish that lands in the narrow window + * between "fiber scheduled" and "fiber starts running". + */ + readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; +} + +export class ProviderInstanceRegistry extends Context.Service< + ProviderInstanceRegistry, + ProviderInstanceRegistryShape +>()("t3/provider/Services/ProviderInstanceRegistry") {} diff --git a/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts new file mode 100644 index 00000000000..98d45080237 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts @@ -0,0 +1,52 @@ +/** + * ProviderInstanceRegistryMutator — internal handle used by the hydration + * layer to reconcile the live registry with a fresh + * `ProviderInstanceConfigMap`. + * + * Kept separate from the public `ProviderInstanceRegistry` service tag so + * downstream consumers (drivers, reactors, `ProviderService`) can only read + * from the registry. Only the hydration layer — which watches + * `ServerSettingsService.streamChanges` and applies diffs — imports this + * tag. + * + * The mutator exposes a single entry point, `reconcile(configMap)`, which: + * + * 1. Diffs the incoming map against the live one keyed by instance id. + * 2. Closes the per-instance `Scope` of every removed or replaced entry + * (tearing down adapter processes, refresh fibres, temp files) BEFORE + * creating the replacement — `reconcile` guarantees "at most one live + * instance per id" at all times. + * 3. Opens a fresh child `Scope` for every added or replaced entry, runs + * the driver's `create`, and stores the resulting `ProviderInstance` + * plus its scope. + * 4. Publishes one `void` tick on the registry's `streamChanges` PubSub at + * the end of the batch — consumers re-pull `listInstances` / + * `listUnavailable`. + * + * `reconcile` is idempotent: calling it with an unchanged config map is a + * no-op (no scope churn, no pubsub emission). + * + * @module provider/Services/ProviderInstanceRegistryMutator + */ +import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface ProviderInstanceRegistryMutatorShape { + /** + * Bring the live registry in line with the supplied config map. See + * module docs for the add / remove / replace semantics. + * + * The effect never fails: individual driver `create` failures are + * captured as "unavailable" shadow snapshots inside the registry, the + * same way boot-time failures are handled by + * `makeProviderInstanceRegistry`. This keeps settings-watcher loops from + * erroring out on a single bad entry. + */ + readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; +} + +export class ProviderInstanceRegistryMutator extends Context.Service< + ProviderInstanceRegistryMutator, + ProviderInstanceRegistryMutatorShape +>()("t3/provider/Services/ProviderInstanceRegistryMutator") {} diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 2e04fa253b0..b7426b30338 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -6,23 +6,72 @@ * * @module ProviderRegistry */ -import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect, Stream } from "effect"; +import type { + ProviderInstanceId, + ProviderDriverKind, + ServerProvider, + ServerProviderUpdateState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; +import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; + +export type ProviderMaintenanceActionKind = "update"; export interface ProviderRegistryShape { /** - * Read the latest provider snapshots. + * Read the latest provider snapshots for every configured instance. + * Multiple snapshots may share the same `provider` kind (multiple + * instances of the same driver) and disambiguate via `instanceId`. */ readonly getProviders: Effect.Effect>; /** - * Refresh all providers, or a single provider when specified. + * Refresh all providers, or the default instance of the specified + * kind when supplied. + * + * Retained for back-compat with legacy call sites (WS refresh RPC, + * orchestration metrics). New code should prefer `refreshInstance`. + * + * @deprecated prefer `refreshInstance` for new call sites. + */ + readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect>; + + /** + * Refresh the specific configured instance. Returns the updated snapshot + * list. When the instance id is unknown the call resolves with the + * currently cached list (no error) — matching the legacy `refresh` shim + * behaviour so transport layers don't have to special-case unknowns. + */ + readonly refreshInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + + /** + * Resolve the maintenance capabilities owned by one live provider instance. + * Falls back to manual-only capabilities when the instance is not live. + */ + readonly getProviderMaintenanceCapabilitiesForInstance: ( + instanceId: ProviderInstanceId, + provider: ProviderDriverKind, + ) => Effect.Effect; + + /** + * Apply volatile maintenance-action state to one configured instance. + * This state is never persisted to disk. Today only update actions are + * projected onto `ServerProvider.updateState`; install/auth actions can + * extend this action map without adding driver-scoped APIs. */ - readonly refresh: (provider?: ProviderKind) => Effect.Effect>; + readonly setProviderMaintenanceActionState: (input: { + readonly instanceId: ProviderInstanceId; + readonly action: ProviderMaintenanceActionKind; + readonly state: ServerProviderUpdateState | null; + }) => Effect.Effect>; /** - * Stream of provider snapshot updates. + * Stream of provider snapshot updates — one emission per aggregated + * change. The array contains the full current state. */ readonly streamChanges: Stream.Stream>; } diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 1e461fcd1c6..4d4cb4fa01a 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -13,7 +13,7 @@ */ import type { ProviderInterruptTurnInput, - ProviderKind, + ProviderInstanceId, ProviderRespondToRequestInput, ProviderRespondToUserInputInput, ProviderRuntimeEvent, @@ -24,11 +24,13 @@ import type { ThreadId, ProviderTurnStartResult, } from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect, Stream } from "effect"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; import type { ProviderServiceError } from "../Errors.ts"; import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; +import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts"; /** * ProviderServiceShape - Service API for provider session and turn orchestration. @@ -85,12 +87,16 @@ export interface ProviderServiceShape { readonly listSessions: () => Effect.Effect>; /** - * Read static capabilities for a provider adapter. + * Read capabilities for the adapter bound to a configured provider instance. */ readonly getCapabilities: ( - provider: ProviderKind, + instanceId: ProviderInstanceId, ) => Effect.Effect; + readonly getInstanceInfo: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + /** * Roll back provider conversation state by a number of turns. */ diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index aa0483620b4..f2dd4323f7a 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,11 +1,13 @@ import type { - ProviderKind, + ProviderInstanceId, + ProviderDriverKind, ProviderSessionRuntimeStatus, RuntimeMode, ThreadId, } from "@t3tools/contracts"; -import { Option, Context } from "effect"; -import type { Effect } from "effect"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProviderSessionDirectoryPersistenceError, @@ -14,7 +16,13 @@ import type { export interface ProviderRuntimeBinding { readonly threadId: ThreadId; - readonly provider: ProviderKind; + readonly provider: ProviderDriverKind; + /** + * Routing key for the configured provider instance that owns this + * session. The persistence layer promotes legacy null rows before + * exposing bindings; runtime callers must not infer this from `provider`. + */ + readonly providerInstanceId?: ProviderInstanceId; readonly adapterKey?: string; readonly status?: ProviderSessionRuntimeStatus; readonly resumeCursor?: unknown | null; @@ -22,6 +30,10 @@ export interface ProviderRuntimeBinding { readonly runtimeMode?: RuntimeMode; } +export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding { + readonly lastSeenAt: string; +} + export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; export type ProviderSessionDirectoryWriteError = @@ -35,20 +47,21 @@ export interface ProviderSessionDirectoryShape { readonly getProvider: ( threadId: ThreadId, - ) => Effect.Effect; + ) => Effect.Effect; readonly getBinding: ( threadId: ThreadId, ) => Effect.Effect, ProviderSessionDirectoryReadError>; - readonly remove: ( - threadId: ThreadId, - ) => Effect.Effect; - readonly listThreadIds: () => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError >; + + readonly listBindings: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; } export class ProviderSessionDirectory extends Context.Service< diff --git a/apps/server/src/provider/Services/ProviderSessionReaper.ts b/apps/server/src/provider/Services/ProviderSessionReaper.ts new file mode 100644 index 00000000000..7c4627eca89 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderSessionReaper.ts @@ -0,0 +1,15 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface ProviderSessionReaperShape { + /** + * Start the background provider session reaper within the provided scope. + */ + readonly start: () => Effect.Effect; +} + +export class ProviderSessionReaper extends Context.Service< + ProviderSessionReaper, + ProviderSessionReaperShape +>()("t3/provider/Services/ProviderSessionReaper") {} diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index 4df0bc8fc2f..12162512927 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -1,7 +1,10 @@ import type { ServerProvider } from "@t3tools/contracts"; -import type { Effect, Stream } from "effect"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; +import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; export interface ServerProviderShape { + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; readonly streamChanges: Stream.Stream; diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts new file mode 100644 index 00000000000..a7fcdc4c827 --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import * as EffectAcpErrors from "effect-acp/errors"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; + +describe("AcpAdapterSupport", () => { + it("maps ACP approval decisions to permission outcomes", () => { + expect(acpPermissionOutcome("accept")).toBe("allow-once"); + expect(acpPermissionOutcome("acceptForSession")).toBe("allow-always"); + expect(acpPermissionOutcome("decline")).toBe("reject-once"); + }); + + it("maps ACP request errors to provider adapter request errors", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("cursor"), + "thread-1" as never, + "session/prompt", + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: "Invalid params", + }), + ); + + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.message).toContain("Invalid params"); + }); +}); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts new file mode 100644 index 00000000000..cde110e6dd9 --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -0,0 +1,56 @@ +import { + type ProviderApprovalDecision, + type ProviderDriverKind, + type ThreadId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + type ProviderAdapterError, +} from "../Errors.ts"; +const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); + +export function mapAcpToAdapterError( + provider: ProviderDriverKind, + threadId: ThreadId, + method: string, + error: EffectAcpErrors.AcpError, +): ProviderAdapterError { + if (isAcpProcessExitedError(error)) { + return new ProviderAdapterSessionClosedError({ + provider, + threadId, + cause: error, + }); + } + if (isAcpRequestError(error)) { + return new ProviderAdapterRequestError({ + provider, + method, + detail: error.message, + cause: error, + }); + } + return new ProviderAdapterRequestError({ + provider, + method, + detail: error.message, + cause: error, + }); +} + +export function acpPermissionOutcome(decision: ProviderApprovalDecision): string { + switch (decision) { + case "acceptForSession": + return "allow-always"; + case "accept": + return "allow-once"; + case "decline": + default: + return "reject-once"; + } +} diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts new file mode 100644 index 00000000000..713d0668928 --- /dev/null +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -0,0 +1,155 @@ +import { ProviderDriverKind, RuntimeRequestId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "./AcpCoreRuntimeEvents.ts"; + +describe("AcpCoreRuntimeEvents", () => { + it("maps ACP permission requests to canonical runtime events", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.make("turn-1"); + const permissionRequest = { + kind: "execute" as const, + detail: "cat package.json", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "pending" as const, + command: "cat package.json", + detail: "cat package.json", + data: { toolCallId: "tool-1", kind: "execute" }, + }, + }; + + expect( + makeAcpRequestOpenedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.make("request-1"), + permissionRequest, + detail: "cat package.json", + args: { command: ["cat", "package.json"] }, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "request.opened", + payload: { + requestType: "exec_command_approval", + detail: "cat package.json", + }, + }); + + expect( + makeAcpRequestResolvedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.make("request-1"), + permissionRequest, + decision: "accept", + }), + ).toMatchObject({ + type: "request.resolved", + payload: { + requestType: "exec_command_approval", + decision: "accept", + }, + }); + }); + + it("maps ACP core plan, tool-call, and content updates", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.make("turn-1"); + + expect( + makeAcpPlanUpdatedEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + payload: { + plan: [{ step: "Inspect state", status: "inProgress" }], + }, + source: "acp.cursor.extension", + method: "cursor/update_todos", + rawPayload: { todos: [] }, + }), + ).toMatchObject({ + type: "turn.plan.updated", + raw: { + method: "cursor/update_todos", + }, + }); + + expect( + makeAcpToolCallEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "completed", + title: "Terminal", + detail: "bun run test", + data: { command: "bun run test" }, + }, + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "item.completed", + payload: { + itemType: "command_execution", + status: "completed", + }, + }); + + expect( + makeAcpContentDeltaEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + itemId: "assistant:session-1:segment:0", + text: "hello", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "content.delta", + itemId: "assistant:session-1:segment:0", + payload: { + delta: "hello", + }, + }); + + expect( + makeAcpAssistantItemEvent({ + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId, + itemId: "assistant:session-1:segment:0", + lifecycle: "item.started", + }), + ).toMatchObject({ + type: "item.started", + itemId: "assistant:session-1:segment:0", + payload: { + itemType: "assistant_message", + status: "inProgress", + }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts new file mode 100644 index 00000000000..c93e61dc37b --- /dev/null +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -0,0 +1,242 @@ +import { + type RuntimeEventRawSource, + RuntimeItemId, + type CanonicalRequestType, + type EventId, + type ProviderApprovalDecision, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type RuntimeRequestId, + type ThreadId, + type ToolLifecycleItemType, + type TurnId, +} from "@t3tools/contracts"; + +import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts"; + +type AcpAdapterRawSource = Extract< + RuntimeEventRawSource, + "acp.jsonrpc" | `acp.${string}.extension` +>; + +interface AcpEventStamp { + readonly eventId: EventId; + readonly createdAt: string; +} + +type AcpCanonicalRequestType = Extract< + CanonicalRequestType, + "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" +>; + +function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { + switch (kind) { + case "execute": + return "exec_command_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "delete": + case "move": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function runtimeItemStatusFromAcpToolStatus( + status: AcpToolCallState["status"], +): "inProgress" | "completed" | "failed" | undefined { + switch (status) { + case "pending": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return undefined; + } +} + +export function makeAcpRequestOpenedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly permissionRequest: AcpPermissionRequest; + readonly detail: string; + readonly args: unknown; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "request.opened", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), + detail: input.detail, + args: input.args, + }, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpRequestResolvedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly permissionRequest: AcpPermissionRequest; + readonly decision: ProviderApprovalDecision; +}): ProviderRuntimeEvent { + return { + type: "request.resolved", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), + decision: input.decision, + }, + }; +} + +export function makeAcpPlanUpdatedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly payload: AcpPlanUpdate; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "turn.plan.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: input.payload, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpToolCallEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); + return { + type: + input.toolCall.status === "completed" || input.toolCall.status === "failed" + ? "item.completed" + : "item.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.make(input.toolCall.toolCallId), + payload: { + itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), + ...(runtimeStatus ? { status: runtimeStatus } : {}), + ...(input.toolCall.title ? { title: input.toolCall.title } : {}), + ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), + ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} + +export function makeAcpAssistantItemEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly itemId: string; + readonly lifecycle: "item.started" | "item.completed"; +}): ProviderRuntimeEvent { + return { + type: input.lifecycle, + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.make(input.itemId), + payload: { + itemType: "assistant_message", + status: input.lifecycle === "item.completed" ? "completed" : "inProgress", + }, + }; +} + +export function makeAcpContentDeltaEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly itemId?: string; + readonly text: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "content.delta", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + payload: { + streamKind: "assistant_text", + delta: input.text, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts new file mode 100644 index 00000000000..1b8f7be5d7d --- /dev/null +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -0,0 +1,397 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as path from "node:path"; +import * as os from "node:os"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { describe, expect } from "vitest"; + +import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const bunExe = "bun"; + +describe("AcpSessionRuntime", () => { + it.effect("merges custom initialize client capabilities into the ACP handshake", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + const initializeStarted = requestEvents.find( + (event) => event.method === "initialize" && event.status === "started", + ); + expect(initializeStarted?.payload).toMatchObject({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { parameterizedModelPicker: true }, + }, + }); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); + + expect(started.initializeResult).toMatchObject({ protocolVersion: 1 }); + expect(started.sessionId).toBe("mock-session-1"); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + expect(notes).toHaveLength(4); + expect(notes.map((note) => note._tag)).toEqual([ + "PlanUpdated", + "AssistantItemStarted", + "ContentDelta", + "AssistantItemCompleted", + ]); + const planUpdate = notes.find((note) => note._tag === "PlanUpdated"); + expect(planUpdate?._tag).toBe("PlanUpdated"); + if (planUpdate?._tag === "PlanUpdated") { + expect(planUpdate.payload.plan).toHaveLength(2); + } + const assistantStart = notes[1]; + const assistantDelta = notes[2]; + if ( + assistantStart?._tag === "AssistantItemStarted" && + assistantDelta?._tag === "ContentDelta" + ) { + expect(assistantDelta.itemId).toBe(assistantStart.itemId); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("segments assistant text around ACP tool calls", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7))); + expect(notes.map((note) => note._tag)).toEqual([ + "AssistantItemStarted", + "ContentDelta", + "AssistantItemCompleted", + "ToolCallUpdated", + "ToolCallUpdated", + "AssistantItemStarted", + "ContentDelta", + ]); + + const firstStarted = notes[0]; + const firstDelta = notes[1]; + const firstCompleted = notes[2]; + const secondStarted = notes[5]; + const secondDelta = notes[6]; + expect(firstStarted?._tag).toBe("AssistantItemStarted"); + expect(firstCompleted?._tag).toBe("AssistantItemCompleted"); + expect(secondStarted?._tag).toBe("AssistantItemStarted"); + if ( + firstStarted?._tag === "AssistantItemStarted" && + firstDelta?._tag === "ContentDelta" && + firstCompleted?._tag === "AssistantItemCompleted" && + secondStarted?._tag === "AssistantItemStarted" && + secondDelta?._tag === "ContentDelta" + ) { + expect(firstDelta.itemId).toBe(firstStarted.itemId); + expect(firstCompleted.itemId).toBe(firstStarted.itemId); + expect(secondStarted.itemId).not.toBe(firstStarted.itemId); + expect(secondDelta.itemId).toBe(secondStarted.itemId); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + env: { + T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("suppresses generic placeholder tool updates until completion", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1))); + expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); + const toolCall = notes[0]; + expect(toolCall?._tag).toBe("ToolCallUpdated"); + if (toolCall?._tag === "ToolCallUpdated") { + expect(toolCall.toolCall.status).toBe("completed"); + expect(toolCall.toolCall.title).toBe("Read file"); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + env: { + T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("logs ACP requests from the shared runtime", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.setModel("composer-2"); + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + expect( + requestEvents.some( + (event) => event.method === "session/set_config_option" && event.status === "started", + ), + ).toBe(true); + expect( + requestEvents.some( + (event) => event.method === "session/set_config_option" && event.status === "succeeded", + ), + ).toBe(true); + expect( + requestEvents.some( + (event) => event.method === "session/prompt" && event.status === "started", + ), + ).toBe(true); + expect( + requestEvents.some( + (event) => event.method === "session/prompt" && event.status === "succeeded", + ), + ).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("skips no-op session config writes when the requested value is already active", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.setConfigOption("model", "default"); + yield* runtime.setMode("ask"); + + expect( + requestEvents.some( + (event) => event.method === "session/set_config_option" && event.status === "started", + ), + ).toBe(false); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { + const protocolEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + expect( + protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"), + ).toBe(true); + expect( + protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"), + ).toBe(true); + expect( + protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"), + ).toBe(true); + expect( + protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"), + ).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + protocolLogging: { + logIncoming: true, + logOutgoing: true, + logger: (event) => + Effect.sync(() => { + protocolEvents.push(event); + }), + }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("rejects invalid config option values before sending session/set_config_option", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip); + expect(error._tag).toBe("AcpRequestError"); + if (error._tag === "AcpRequestError") { + expect(error.code).toBe(-32602); + expect(error.message).toContain( + 'Invalid value "composer-2[fast=false]" for session config option "model"', + ); + expect(error.message).toContain("composer-2[fast=true]"); + } + + const recordedRequests = readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } }); + expect( + recordedRequests.some( + (message) => + message.method === "session/set_config_option" && + message.params?.value === "composer-2[fast=false]", + ), + ).toBe(false); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + env: { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts new file mode 100644 index 00000000000..f00ffcb6655 --- /dev/null +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -0,0 +1,79 @@ +import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Random from "effect/Random"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; + +import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts"; + +function writeNativeAcpLog(input: { + readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly kind: "request" | "protocol"; + readonly payload: unknown; +}): Effect.Effect { + return Effect.gen(function* () { + if (!input.nativeEventLogger) return; + const observedAt = DateTime.formatIso(yield* DateTime.now); + yield* input.nativeEventLogger.write( + { + observedAt, + event: { + id: yield* Random.nextUUIDv4, + kind: input.kind, + provider: input.provider, + createdAt: observedAt, + threadId: input.threadId, + payload: input.payload, + }, + }, + input.threadId, + ); + }); +} + +function formatRequestLogPayload(event: AcpSessionRequestLogEvent) { + return { + method: event.method, + status: event.status, + request: event.payload, + ...(event.result !== undefined ? { result: event.result } : {}), + ...(event.cause !== undefined ? { cause: Cause.pretty(event.cause) } : {}), + }; +} + +export function makeAcpNativeLoggers(input: { + readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; +}): Pick { + return { + requestLogger: (event) => + writeNativeAcpLog({ + nativeEventLogger: input.nativeEventLogger, + provider: input.provider, + threadId: input.threadId, + kind: "request", + payload: formatRequestLogPayload(event), + }), + ...(input.nativeEventLogger + ? { + protocolLogging: { + logIncoming: true, + logOutgoing: true, + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => + writeNativeAcpLog({ + nativeEventLogger: input.nativeEventLogger, + provider: input.provider, + threadId: input.threadId, + kind: "protocol", + payload: event, + }), + } satisfies NonNullable, + } + : {}), + }; +} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts new file mode 100644 index 00000000000..ae12d3112aa --- /dev/null +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; + +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + extractModelConfigId, + mergeToolCallState, + parsePermissionRequest, + parseSessionModeState, + parseSessionUpdateEvent, +} from "./AcpRuntimeModel.ts"; + +describe("AcpRuntimeModel", () => { + it("parses session mode state from typed ACP session setup responses", () => { + const modeState = parseSessionModeState({ + sessionId: "session-1", + modes: { + currentModeId: " code ", + availableModes: [ + { id: " ask ", name: " Ask ", description: " Request approval " }, + { id: " code ", name: " Code " }, + ], + }, + configOptions: [], + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(modeState).toEqual({ + currentModeId: "code", + availableModes: [ + { id: "ask", name: "Ask", description: "Request approval" }, + { id: "code", name: "Code" }, + ], + }); + }); + + it("extracts the model config id from typed ACP config options", () => { + const modelConfigId = extractModelConfigId({ + sessionId: "session-1", + configOptions: [ + { + id: "approval", + name: "Approval Mode", + category: "permission", + type: "select", + currentValue: "ask", + options: [{ value: "ask", name: "Ask" }], + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "default", + options: [{ value: "default", name: "Auto" }], + }, + ], + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(modelConfigId).toBe("model"); + }); + + it("projects typed ACP tool call updates into runtime events", () => { + const created = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(created.events).toEqual([ + { + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + title: "Ran command", + status: "pending", + command: "bun run typecheck", + detail: "bun run typecheck", + data: { + toolCallId: "tool-1", + kind: "execute", + command: "bun run typecheck", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + }, + }, + ]); + + const updated = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { exitCode: 0 }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(updated.events).toHaveLength(1); + expect(updated.events[0]?._tag).toBe("ToolCallUpdated"); + const createdEvent = created.events[0]; + const updatedEvent = updated.events[0]; + if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") { + expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({ + toolCallId: "tool-1", + status: "completed", + title: "Ran command", + detail: "bun run typecheck", + command: "bun run typecheck", + }); + } + }); + + it("trims padded current mode updates before emitting a mode change", () => { + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "current_mode_update", + currentModeId: " code ", + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.modeId).toBe("code"); + expect(result.events).toEqual([ + { + _tag: "ModeChanged", + modeId: "code", + }, + ]); + }); + + it("projects typed ACP plan and content updates", () => { + const planResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { content: " Inspect state ", priority: "high", status: "completed" }, + { content: "", priority: "medium", status: "in_progress" }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(planResult.events).toEqual([ + { + _tag: "PlanUpdated", + payload: { + plan: [ + { step: "Inspect state", status: "completed" }, + { step: "Step 2", status: "inProgress" }, + ], + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { content: " Inspect state ", priority: "high", status: "completed" }, + { content: "", priority: "medium", status: "in_progress" }, + ], + }, + }, + }, + ]); + + const contentResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello from acp", + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(contentResult.events).toEqual([ + { + _tag: "ContentDelta", + text: "hello from acp", + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello from acp", + }, + }, + }, + }, + ]); + }); + + it("keeps permission request parsing compatible with loose extension payloads", () => { + const request = parsePermissionRequest({ + sessionId: "session-1", + options: [ + { + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + ], + toolCall: { + toolCallId: "tool-1", + title: "`cat package.json`", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Not in allowlist", + }, + }, + ], + }, + }); + + expect(request).toMatchObject({ + kind: "execute", + detail: "cat package.json", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "pending", + command: "cat package.json", + }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts new file mode 100644 index 00000000000..ffd214a5bf1 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -0,0 +1,482 @@ +import type * as EffectAcpSchema from "effect-acp/schema"; +import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity"; +import type { ToolLifecycleItemType } from "@t3tools/contracts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export interface AcpSessionMode { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +export interface AcpSessionModeState { + readonly currentModeId: string; + readonly availableModes: ReadonlyArray; +} + +export interface AcpToolCallState { + readonly toolCallId: string; + readonly kind?: string; + readonly title?: string; + readonly status?: "pending" | "inProgress" | "completed" | "failed"; + readonly command?: string; + readonly detail?: string; + readonly data: Record; +} + +export interface AcpPlanUpdate { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; +} + +export interface AcpPermissionRequest { + readonly kind: string | "unknown"; + readonly detail?: string; + readonly toolCall?: AcpToolCallState; +} + +export type AcpParsedSessionEvent = + | { + readonly _tag: "ModeChanged"; + readonly modeId: string; + } + | { + readonly _tag: "AssistantItemStarted"; + readonly itemId: string; + } + | { + readonly _tag: "AssistantItemCompleted"; + readonly itemId: string; + } + | { + readonly _tag: "PlanUpdated"; + readonly payload: AcpPlanUpdate; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ToolCallUpdated"; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ContentDelta"; + readonly itemId?: string; + readonly text: string; + readonly rawPayload: unknown; + }; + +type AcpSessionSetupResponse = + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + +type AcpToolCallUpdate = Extract< + EffectAcpSchema.SessionNotification["update"], + { readonly sessionUpdate: "tool_call" | "tool_call_update" } +>; + +export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { + const configOptions = sessionResponse.configOptions; + if (!configOptions) return undefined; + for (const opt of configOptions) { + if (opt.category === "model" && opt.id.trim().length > 0) { + return opt.id.trim(); + } + } + return undefined; +} + +export function findSessionConfigOption( + configOptions: ReadonlyArray | null | undefined, + configId: string, +): EffectAcpSchema.SessionConfigOption | undefined { + if (!configOptions) { + return undefined; + } + const normalizedConfigId = configId.trim(); + if (!normalizedConfigId) { + return undefined; + } + return configOptions.find((option) => option.id.trim() === normalizedConfigId); +} + +export function collectSessionConfigOptionValues( + configOption: EffectAcpSchema.SessionConfigOption, +): ReadonlyArray { + if (configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((option) => option.value), + ); +} + +export function parseSessionModeState( + sessionResponse: AcpSessionSetupResponse, +): AcpSessionModeState | undefined { + const modes = sessionResponse.modes; + if (!modes) return undefined; + const currentModeId = modes.currentModeId.trim(); + if (!currentModeId) { + return undefined; + } + const availableModes = modes.availableModes + .map((mode) => { + const id = mode.id.trim(); + const name = mode.name.trim(); + if (!id || !name) { + return undefined; + } + const description = mode.description?.trim() || undefined; + return description !== undefined + ? ({ id, name, description } satisfies AcpSessionMode) + : ({ id, name } satisfies AcpSessionMode); + }) + .filter((mode): mode is AcpSessionMode => mode !== undefined); + if (availableModes.length === 0) { + return undefined; + } + return { + currentModeId, + availableModes, + }; +} + +function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { + switch (raw) { + case "completed": + return "completed"; + case "in_progress": + case "inProgress": + return "inProgress"; + default: + return "pending"; + } +} + +function normalizeToolCallStatus( + raw: unknown, + fallback?: "pending" | "inProgress" | "completed" | "failed", +): "pending" | "inProgress" | "completed" | "failed" | undefined { + switch (raw) { + case "pending": + return "pending"; + case "in_progress": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return fallback; + } +} + +function normalizeCommandValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .map((entry) => (typeof entry === "string" && entry.trim().length > 0 ? entry.trim() : null)) + .filter((entry): entry is string => entry !== null); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function extractCommandFromTitle(title: string | undefined): string | undefined { + if (!title) { + return undefined; + } + const match = /`([^`]+)`/.exec(title); + return match?.[1]?.trim() || undefined; +} + +function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined { + if (isRecord(rawInput)) { + const directCommand = normalizeCommandValue(rawInput.command); + if (directCommand) { + return directCommand; + } + const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; + const args = normalizeCommandValue(rawInput.args); + if (executable && args) { + return `${executable} ${args}`; + } + if (executable) { + return executable; + } + } + return extractCommandFromTitle(title); +} + +function extractTextContentFromToolCallContent( + content: ReadonlyArray | null | undefined, +): string | undefined { + if (!content) return undefined; + const chunks = content + .map((entry) => { + if (entry.type !== "content") { + return undefined; + } + const nestedContent = entry.content; + if (nestedContent.type !== "text") { + return undefined; + } + return nestedContent.text.trim().length > 0 ? nestedContent.text.trim() : undefined; + }) + .filter((entry): entry is string => entry !== undefined); + return chunks.length > 0 ? chunks.join("\n") : undefined; +} + +function normalizeToolKind(kind: unknown): string | undefined { + return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; +} + +function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function makeToolCallState( + input: { + readonly toolCallId: string; + readonly title?: string | null | undefined; + readonly kind?: EffectAcpSchema.ToolKind | null | undefined; + readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly content?: ReadonlyArray | null | undefined; + readonly locations?: ReadonlyArray | null | undefined; + }, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + const toolCallId = input.toolCallId.trim(); + if (!toolCallId) { + return undefined; + } + const title = input.title?.trim() || undefined; + const command = extractToolCallCommand(input.rawInput, title); + const textContent = extractTextContentFromToolCallContent(input.content); + const normalizedTitle = + title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" + ? title + : undefined; + const data: Record = { toolCallId }; + const kind = normalizeToolKind(input.kind); + if (kind) { + data.kind = kind; + } + if (command) { + data.command = command; + } + if (input.rawInput !== undefined) { + data.rawInput = input.rawInput; + } + if (input.rawOutput !== undefined) { + data.rawOutput = input.rawOutput; + } + if (input.content !== undefined) { + data.content = input.content; + } + if (input.locations !== undefined) { + data.locations = input.locations; + } + const fallbackDetail = command ?? normalizedTitle ?? textContent; + const hasPresentationSeed = + title !== undefined || + kind !== undefined || + command !== undefined || + normalizedTitle !== undefined || + textContent !== undefined; + const presentation = hasPresentationSeed + ? deriveToolActivityPresentation({ + itemType: canonicalItemTypeFromAcpToolKind(kind), + title, + detail: fallbackDetail, + data, + fallbackSummary: title ?? "Tool", + }) + : undefined; + const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); + return { + toolCallId, + ...(kind ? { kind } : {}), + ...(presentation?.summary ? { title: presentation.summary } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(presentation?.detail ? { detail: presentation.detail } : {}), + data, + }; +} + +function parseTypedToolCallState( + event: AcpToolCallUpdate, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + return makeToolCallState( + { + toolCallId: event.toolCallId, + title: event.title, + kind: event.kind, + status: event.status, + rawInput: event.rawInput, + rawOutput: event.rawOutput, + content: event.content, + locations: event.locations, + }, + options, + ); +} + +export function mergeToolCallState( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): AcpToolCallState { + const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined; + const kind = nextKind ?? previous?.kind; + const title = next.title ?? previous?.title; + const status = next.status ?? previous?.status; + const command = next.command ?? previous?.command; + const detail = next.detail ?? previous?.detail; + return { + toolCallId: next.toolCallId, + ...(kind ? { kind } : {}), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data: { + ...previous?.data, + ...next.data, + }, + }; +} + +export function parsePermissionRequest( + params: EffectAcpSchema.RequestPermissionRequest, +): AcpPermissionRequest { + const toolCall = makeToolCallState( + { + toolCallId: params.toolCall.toolCallId, + title: params.toolCall.title, + kind: params.toolCall.kind, + status: params.toolCall.status, + rawInput: params.toolCall.rawInput, + rawOutput: params.toolCall.rawOutput, + content: params.toolCall.content, + locations: params.toolCall.locations, + }, + { fallbackStatus: "pending" }, + ); + const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; + const detail = + toolCall?.command ?? + toolCall?.title ?? + toolCall?.detail ?? + (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); + return { + kind, + ...(detail ? { detail } : {}), + ...(toolCall ? { toolCall } : {}), + }; +} + +export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { + readonly modeId?: string; + readonly events: ReadonlyArray; +} { + const upd = params.update; + const events: Array = []; + let modeId: string | undefined; + + switch (upd.sessionUpdate) { + case "current_mode_update": { + modeId = upd.currentModeId.trim(); + if (modeId) { + events.push({ + _tag: "ModeChanged", + modeId, + }); + } + break; + } + case "plan": { + const plan = upd.entries.map((entry, index) => ({ + step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, + status: normalizePlanStepStatus(entry.status), + })); + if (plan.length > 0) { + events.push({ + _tag: "PlanUpdated", + payload: { + plan, + }, + rawPayload: params, + }); + } + break; + } + case "tool_call": { + const toolCall = parseTypedToolCallState(upd, { + fallbackStatus: "pending", + }); + if (toolCall) { + events.push({ + _tag: "ToolCallUpdated", + toolCall, + rawPayload: params, + }); + } + break; + } + case "tool_call_update": { + const toolCall = parseTypedToolCallState(upd); + if (toolCall) { + events.push({ + _tag: "ToolCallUpdated", + toolCall, + rawPayload: params, + }); + } + break; + } + case "agent_message_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "ContentDelta", + text: upd.content.text, + rawPayload: params, + }); + } + break; + } + default: + break; + } + + return { ...(modeId !== undefined ? { modeId } : {}), events }; +} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts new file mode 100644 index 00000000000..8652b2cfeaf --- /dev/null +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -0,0 +1,735 @@ +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Context from "effect/Context"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as EffectAcpClient from "effect-acp/client"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; + +import { + collectSessionConfigOptionValues, + extractModelConfigId, + findSessionConfigOption, + mergeToolCallState, + parseSessionModeState, + parseSessionUpdateEvent, + type AcpParsedSessionEvent, + type AcpSessionModeState, + type AcpToolCallState, +} from "./AcpRuntimeModel.ts"; + +function formatConfigOptionValue(value: string | boolean): string { + return JSON.stringify(value); +} + +export interface AcpSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; +} + +export interface AcpSessionRuntimeOptions { + readonly spawn: AcpSpawnInput; + readonly cwd: string; + readonly resumeSessionId?: string; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; + readonly clientInfo: { + readonly name: string; + readonly version: string; + }; + readonly authMethodId: string; + readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; + readonly protocolLogging?: { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect; + }; +} + +export interface AcpSessionRequestLogEvent { + readonly method: string; + readonly payload: unknown; + readonly status: "started" | "succeeded" | "failed"; + readonly result?: unknown; + readonly cause?: Cause.Cause; +} + +export interface AcpSessionRuntimeStartResult { + readonly sessionId: string; + readonly initializeResult: EffectAcpSchema.InitializeResponse; + readonly sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + readonly modelConfigId: string | undefined; +} + +export interface AcpSessionRuntimeShape { + readonly handleRequestPermission: EffectAcpClient.AcpClientShape["handleRequestPermission"]; + readonly handleElicitation: EffectAcpClient.AcpClientShape["handleElicitation"]; + readonly handleReadTextFile: EffectAcpClient.AcpClientShape["handleReadTextFile"]; + readonly handleWriteTextFile: EffectAcpClient.AcpClientShape["handleWriteTextFile"]; + readonly handleCreateTerminal: EffectAcpClient.AcpClientShape["handleCreateTerminal"]; + readonly handleTerminalOutput: EffectAcpClient.AcpClientShape["handleTerminalOutput"]; + readonly handleTerminalWaitForExit: EffectAcpClient.AcpClientShape["handleTerminalWaitForExit"]; + readonly handleTerminalKill: EffectAcpClient.AcpClientShape["handleTerminalKill"]; + readonly handleTerminalRelease: EffectAcpClient.AcpClientShape["handleTerminalRelease"]; + readonly handleSessionUpdate: EffectAcpClient.AcpClientShape["handleSessionUpdate"]; + readonly handleElicitationComplete: EffectAcpClient.AcpClientShape["handleElicitationComplete"]; + readonly handleUnknownExtRequest: EffectAcpClient.AcpClientShape["handleUnknownExtRequest"]; + readonly handleUnknownExtNotification: EffectAcpClient.AcpClientShape["handleUnknownExtNotification"]; + readonly handleExtRequest: EffectAcpClient.AcpClientShape["handleExtRequest"]; + readonly handleExtNotification: EffectAcpClient.AcpClientShape["handleExtNotification"]; + readonly start: () => Effect.Effect; + readonly getEvents: () => Stream.Stream; + readonly getModeState: Effect.Effect; + readonly getConfigOptions: Effect.Effect>; + readonly prompt: ( + payload: Omit, + ) => Effect.Effect; + readonly cancel: Effect.Effect; + readonly setMode: ( + modeId: string, + ) => Effect.Effect; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + readonly notify: ( + method: string, + payload: unknown, + ) => Effect.Effect; +} + +interface AcpStartedState extends AcpSessionRuntimeStartResult {} + +type AcpStartState = + | { readonly _tag: "NotStarted" } + | { + readonly _tag: "Starting"; + readonly deferred: Deferred.Deferred; + } + | { readonly _tag: "Started"; readonly result: AcpStartedState }; + +interface AcpAssistantSegmentState { + readonly nextSegmentIndex: number; + readonly activeItemId?: string; +} + +interface EnsureActiveAssistantSegmentResult { + readonly itemId: string; + readonly startedEvent?: Extract; +} + +export class AcpSessionRuntime extends Context.Service()( + "t3/provider/acp/AcpSessionRuntime", +) { + static layer( + options: AcpSessionRuntimeOptions, + ): Layer.Layer< + AcpSessionRuntime, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner + > { + return Layer.effect(AcpSessionRuntime, makeAcpSessionRuntime(options)); + } +} + +const makeAcpSessionRuntime = ( + options: AcpSessionRuntimeOptions, +): Effect.Effect< + AcpSessionRuntimeShape, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + const eventQueue = yield* Queue.unbounded(); + const modeStateRef = yield* Ref.make(undefined); + const toolCallsRef = yield* Ref.make(new Map()); + const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); + const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); + + const logRequest = (event: AcpSessionRequestLogEvent) => + options.requestLogger ? options.requestLogger(event) : Effect.void; + + const runLoggedRequest = ( + method: string, + payload: unknown, + effect: Effect.Effect, + ): Effect.Effect => + logRequest({ method, payload, status: "started" }).pipe( + Effect.flatMap(() => + effect.pipe( + Effect.tap((result) => + logRequest({ + method, + payload, + status: "succeeded", + result, + }), + ), + Effect.onError((cause) => + logRequest({ + method, + payload, + status: "failed", + cause, + }), + ), + ), + ), + ); + + const child = yield* spawner + .spawn( + ChildProcess.make(options.spawn.command, [...options.spawn.args], { + ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), + ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), + shell: process.platform === "win32", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause, + }), + ), + ); + + const acpContext = yield* Layer.build( + EffectAcpClient.layerChildProcess(child, { + ...(options.protocolLogging?.logIncoming !== undefined + ? { logIncoming: options.protocolLogging.logIncoming } + : {}), + ...(options.protocolLogging?.logOutgoing !== undefined + ? { logOutgoing: options.protocolLogging.logOutgoing } + : {}), + ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), + }), + ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); + + const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + + yield* acp.handleSessionUpdate((notification) => + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + toolCallsRef, + assistantSegmentRef, + params: notification, + }), + ); + + const initializeClientCapabilities = { + fs: { + readTextFile: false, + writeTextFile: false, + ...options.clientCapabilities?.fs, + }, + terminal: options.clientCapabilities?.terminal ?? false, + ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), + ...(options.clientCapabilities?.elicitation + ? { elicitation: options.clientCapabilities.elicitation } + : {}), + ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), + } satisfies NonNullable; + + const getStartedState = Effect.gen(function* () { + const state = yield* Ref.get(startStateRef); + if (state._tag === "Started") { + return state.result; + } + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "ACP session runtime has not been started", + cause: "ACP session runtime has not been started", + }); + }); + + const validateConfigOptionValue = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + Effect.gen(function* () { + const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); + if (!configOption) { + return; + } + if (configOption.type === "boolean") { + if (typeof value === "boolean") { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, + data: { + configId: configOption.id, + expectedType: "boolean", + receivedValue: value, + }, + }); + } + if (typeof value !== "string") { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, + data: { + configId: configOption.id, + expectedType: "string", + receivedValue: value, + }, + }); + } + const allowedValues = collectSessionConfigOptionValues(configOption); + if (allowedValues.includes(value)) { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, + data: { + configId: configOption.id, + allowedValues, + receivedValue: value, + }, + }); + }); + + const updateConfigOptions = ( + response: + | EffectAcpSchema.SetSessionConfigOptionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + ): Effect.Effect => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response)); + + const updateCurrentModeId = (modeId: string): Effect.Effect => + Ref.update(modeStateRef, (current) => + current ? { ...current, currentModeId: modeId } : current, + ); + + const setConfigOption = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + validateConfigOptionValue(configId, value).pipe( + Effect.flatMap(() => getStartedState), + Effect.flatMap((started) => + Ref.get(configOptionsRef).pipe( + Effect.flatMap((configOptions) => { + const existing = findSessionConfigOption(configOptions, configId); + if (existing && configOptionCurrentValueMatches(existing, value)) { + return Effect.succeed({ + configOptions, + } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); + } + const requestPayload = + typeof value === "boolean" + ? ({ + sessionId: started.sessionId, + configId, + type: "boolean", + value, + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) + : ({ + sessionId: started.sessionId, + configId, + value: String(value), + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); + return runLoggedRequest( + "session/set_config_option", + requestPayload, + acp.agent.setSessionConfigOption(requestPayload), + ).pipe(Effect.tap((response) => updateConfigOptions(response))); + }), + ), + ), + ); + + const startOnce = Effect.gen(function* () { + const initializePayload = { + protocolVersion: 1, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + } satisfies EffectAcpSchema.InitializeRequest; + + const initializeResult = yield* runLoggedRequest( + "initialize", + initializePayload, + acp.agent.initialize(initializePayload), + ); + + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + + let sessionId: string; + let sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + if (options.resumeSessionId) { + const loadPayload = { + sessionId: options.resumeSessionId, + cwd: options.cwd, + mcpServers: [], + } satisfies EffectAcpSchema.LoadSessionRequest; + const resumed = yield* runLoggedRequest( + "session/load", + loadPayload, + acp.agent.loadSession(loadPayload), + ).pipe(Effect.exit); + if (Exit.isSuccess(resumed)) { + sessionId = options.resumeSessionId; + sessionSetupResult = resumed.value; + } else { + const createPayload = { + cwd: options.cwd, + mcpServers: [], + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.agent.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; + } + } else { + const createPayload = { + cwd: options.cwd, + mcpServers: [], + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.agent.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; + } + + yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + + const nextState = { + sessionId, + initializeResult, + sessionSetupResult, + modelConfigId: extractModelConfigId(sessionSetupResult), + } satisfies AcpStartedState; + return nextState; + }); + + const start = Effect.gen(function* () { + const deferred = yield* Deferred.make< + AcpSessionRuntimeStartResult, + EffectAcpErrors.AcpError + >(); + const effect = yield* Ref.modify(startStateRef, (state) => { + switch (state._tag) { + case "Started": + return [Effect.succeed(state.result), state] as const; + case "Starting": + return [Deferred.await(state.deferred), state] as const; + case "NotStarted": + return [ + startOnce.pipe( + Effect.tap((result) => + Ref.set(startStateRef, { _tag: "Started", result }).pipe( + Effect.andThen(Deferred.succeed(deferred, result)), + ), + ), + Effect.onError((cause) => + Deferred.failCause(deferred, cause).pipe( + Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), + ), + ), + ), + { _tag: "Starting", deferred } satisfies AcpStartState, + ] as const; + } + }); + return yield* effect; + }); + + return { + handleRequestPermission: acp.handleRequestPermission, + handleElicitation: acp.handleElicitation, + handleReadTextFile: acp.handleReadTextFile, + handleWriteTextFile: acp.handleWriteTextFile, + handleCreateTerminal: acp.handleCreateTerminal, + handleTerminalOutput: acp.handleTerminalOutput, + handleTerminalWaitForExit: acp.handleTerminalWaitForExit, + handleTerminalKill: acp.handleTerminalKill, + handleTerminalRelease: acp.handleTerminalRelease, + handleSessionUpdate: acp.handleSessionUpdate, + handleElicitationComplete: acp.handleElicitationComplete, + handleUnknownExtRequest: acp.handleUnknownExtRequest, + handleUnknownExtNotification: acp.handleUnknownExtNotification, + handleExtRequest: acp.handleExtRequest, + handleExtNotification: acp.handleExtNotification, + start: () => start, + getEvents: () => Stream.fromQueue(eventQueue), + getModeState: Ref.get(modeStateRef), + getConfigOptions: Ref.get(configOptionsRef), + prompt: (payload) => + getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + return closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }).pipe( + Effect.andThen( + runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ), + ), + Effect.tap(() => + closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }), + ), + ); + }), + ), + cancel: getStartedState.pipe( + Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })), + ), + setMode: (modeId) => + Ref.get(modeStateRef).pipe( + Effect.flatMap((modeState) => { + if (modeState?.currentModeId === modeId) { + return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse); + } + return setConfigOption("mode", modeId).pipe( + Effect.tap(() => updateCurrentModeId(modeId)), + Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse), + ); + }), + ), + setConfigOption, + setModel: (model) => + getStartedState.pipe( + Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.asVoid, + ), + request: (method, payload) => + runLoggedRequest(method, payload, acp.raw.request(method, payload)), + notify: acp.raw.notify, + } satisfies AcpSessionRuntimeShape; + }); + +function sessionConfigOptionsFromSetup( + response: + | { + readonly configOptions?: ReadonlyArray | null; + } + | undefined, +): ReadonlyArray { + return response?.configOptions ?? []; +} + +function configOptionCurrentValueMatches( + configOption: EffectAcpSchema.SessionConfigOption, + value: string | boolean, +): boolean { + const currentValue = configOption.currentValue; + if (configOption.type === "boolean") { + return currentValue === value; + } + if (typeof currentValue !== "string") { + return false; + } + return currentValue.trim() === String(value).trim(); +} + +const handleSessionUpdate = ({ + queue, + modeStateRef, + toolCallsRef, + assistantSegmentRef, + params, +}: { + readonly queue: Queue.Queue; + readonly modeStateRef: Ref.Ref; + readonly toolCallsRef: Ref.Ref>; + readonly assistantSegmentRef: Ref.Ref; + readonly params: EffectAcpSchema.SessionNotification; +}): Effect.Effect => + Effect.gen(function* () { + const parsed = parseSessionUpdateEvent(params); + if (parsed.modeId) { + yield* Ref.update(modeStateRef, (current) => + current === undefined ? current : updateModeState(current, parsed.modeId!), + ); + } + for (const event of parsed.events) { + if (event._tag === "ToolCallUpdated") { + yield* closeActiveAssistantSegment({ + queue, + assistantSegmentRef, + }); + const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { + const previous = current.get(event.toolCall.toolCallId); + const nextToolCall = mergeToolCallState(previous, event.toolCall); + const next = new Map(current); + if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { + next.delete(nextToolCall.toolCallId); + } else { + next.set(nextToolCall.toolCallId, nextToolCall); + } + return [{ previous, merged: nextToolCall }, next] as const; + }); + if (!shouldEmitToolCallUpdate(previous, merged)) { + continue; + } + yield* Queue.offer(queue, { + _tag: "ToolCallUpdated", + toolCall: merged, + rawPayload: event.rawPayload, + }); + continue; + } + if (event._tag === "ContentDelta") { + if (event.text.trim().length === 0) { + const assistantSegmentState = yield* Ref.get(assistantSegmentRef); + if (!assistantSegmentState.activeItemId) { + continue; + } + } + const itemId = yield* ensureActiveAssistantSegment({ + queue, + assistantSegmentRef, + sessionId: params.sessionId, + }); + yield* Queue.offer(queue, { + ...event, + itemId, + }); + continue; + } + yield* Queue.offer(queue, event); + } + }); + +function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { + const normalized = nextModeId.trim(); + if (!normalized) { + return modeState; + } + return modeState.availableModes.some((mode) => mode.id === normalized) + ? { + ...modeState, + currentModeId: normalized, + } + : modeState; +} + +function shouldEmitToolCallUpdate( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): boolean { + if (next.status === "completed" || next.status === "failed") { + return true; + } + if (!next.detail) { + return false; + } + return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; +} + +const assistantItemId = (sessionId: string, segmentIndex: number) => + `assistant:${sessionId}:segment:${segmentIndex}`; + +const ensureActiveAssistantSegment = ({ + queue, + assistantSegmentRef, + sessionId, +}: { + readonly queue: Queue.Queue; + readonly assistantSegmentRef: Ref.Ref; + readonly sessionId: string; +}) => + Ref.modify( + assistantSegmentRef, + (current) => { + if (current.activeItemId) { + return [{ itemId: current.activeItemId }, current] as const; + } + const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + return [ + { + itemId, + startedEvent: { + _tag: "AssistantItemStarted", + itemId, + } satisfies Extract, + }, + { + nextSegmentIndex: current.nextSegmentIndex + 1, + activeItemId: itemId, + } satisfies AcpAssistantSegmentState, + ] as const; + }, + ).pipe( + Effect.flatMap((result) => + result.startedEvent + ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId)) + : Effect.succeed(result.itemId), + ), + ); + +const closeActiveAssistantSegment = ({ + queue, + assistantSegmentRef, +}: { + readonly queue: Queue.Queue; + readonly assistantSegmentRef: Ref.Ref; +}) => + Ref.modify(assistantSegmentRef, (current) => { + if (!current.activeItemId) { + return [undefined, current] as const; + } + return [ + { + _tag: "AssistantItemCompleted", + itemId: current.activeItemId, + } satisfies AcpParsedSessionEvent, + { + nextSegmentIndex: current.nextSegmentIndex, + } satisfies AcpAssistantSegmentState, + ] as const; + }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts new file mode 100644 index 00000000000..30fbb1842fc --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -0,0 +1,153 @@ +/** + * Optional integration check against a real `agent acp` install. + * Enable with: T3_CURSOR_ACP_PROBE=1 bun run test --filter CursorAcpCliProbe + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vitest"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; + +describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { + it.effect("initialize and authenticate against real agent acp", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + authMethodId: "cursor_login", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("session/new returns configOptions with a model selector", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); + const result = started.sessionSetupResult; + // @effect-diagnostics-next-line preferSchemaOverJson:off + yield* Console.log("session/new result:", JSON.stringify(result, null, 2)); + + expect(typeof started.sessionId).toBe("string"); + + const configOptions = result.configOptions; + // @effect-diagnostics-next-line preferSchemaOverJson:off + yield* Console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2)); + + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find((opt) => opt.category === "model"); + const parameterizedOptions = configOptions.filter( + (opt) => + opt.category === "thought_level" || + opt.category === "model_option" || + opt.category === "model_config", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + yield* Console.log("Model config option:", JSON.stringify(modelConfig, null, 2)); + yield* Console.log( + "Parameterized model config options:", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify(parameterizedOptions, null, 2), + ); + expect(modelConfig).toBeDefined(); + expect(typeof modelConfig?.id).toBe("string"); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "cursor_login", + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("session/set_config_option switches the model in-session", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); + const newResult = started.sessionSetupResult; + + const configOptions = newResult.configOptions; + let modelConfigId = "model"; + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find((opt) => opt.category === "model"); + if (typeof modelConfig?.id === "string") { + modelConfigId = modelConfig.id; + } + } + + const setResult: EffectAcpSchema.SetSessionConfigOptionResponse = + yield* runtime.setConfigOption(modelConfigId, "gpt-5.4"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + yield* Console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2)); + + if (Array.isArray(setResult.configOptions)) { + const modelConfig = setResult.configOptions.find((opt) => opt.category === "model"); + const parameterizedOptions = setResult.configOptions.filter( + (opt) => + opt.category === "thought_level" || + opt.category === "model_option" || + opt.category === "model_config", + ); + if (modelConfig?.type === "select") { + expect(modelConfig.currentValue).toBe("gpt-5.4"); + } + expect(parameterizedOptions.length).toBeGreaterThan(0); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "cursor_login", + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); +}); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.test.ts b/apps/server/src/provider/acp/CursorAcpExtension.test.ts new file mode 100644 index 00000000000..91d50c4a9b8 --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + extractAskQuestions, + extractPlanMarkdown, + extractTodosAsPlan, +} from "./CursorAcpExtension.ts"; + +describe("CursorAcpExtension", () => { + it("extracts ask-question prompts from the real Cursor ACP payload shape", () => { + const questions = extractAskQuestions({ + toolCallId: "ask-1", + title: "Need input", + questions: [ + { + id: "language", + prompt: "Which language should I use?", + options: [ + { id: "ts", label: "TypeScript" }, + { id: "rs", label: "Rust" }, + ], + allowMultiple: false, + }, + ], + }); + + expect(questions).toEqual([ + { + id: "language", + header: "Question", + question: "Which language should I use?", + multiSelect: false, + options: [ + { label: "TypeScript", description: "TypeScript" }, + { label: "Rust", description: "Rust" }, + ], + }, + ]); + }); + + it("defaults ask-question multi-select to false when Cursor omits allowMultiple", () => { + const questions = extractAskQuestions({ + toolCallId: "ask-2", + questions: [ + { + id: "mode", + prompt: "Which mode should I use?", + options: [ + { id: "agent", label: "Agent" }, + { id: "plan", label: "Plan" }, + ], + }, + ], + }); + + expect(questions).toEqual([ + { + id: "mode", + header: "Question", + question: "Which mode should I use?", + multiSelect: false, + options: [ + { label: "Agent", description: "Agent" }, + { label: "Plan", description: "Plan" }, + ], + }, + ]); + }); + + it("extracts plan markdown from the real Cursor create-plan payload shape", () => { + const planMarkdown = extractPlanMarkdown({ + toolCallId: "plan-1", + name: "Refactor parser", + overview: "Tighten ACP parsing", + plan: "# Plan\n\n1. Add schemas\n2. Remove casts", + todos: [ + { id: "t1", content: "Add schemas", status: "in_progress" }, + { id: "t2", content: "Remove casts", status: "pending" }, + ], + isProject: false, + }); + + expect(planMarkdown).toBe("# Plan\n\n1. Add schemas\n2. Remove casts"); + }); + + it("projects todo updates into a plan shape and drops invalid entries", () => { + expect( + extractTodosAsPlan({ + toolCallId: "todos-1", + todos: [ + { id: "1", content: "Inspect state", status: "completed" }, + { id: "2", content: " Apply fix ", status: "in_progress" }, + { id: "3", title: "Fallback title", status: "pending" }, + { id: "4", content: "Unknown status", status: "weird_status" }, + { id: "5", content: " " }, + ], + merge: true, + }), + ).toEqual({ + plan: [ + { step: "Inspect state", status: "completed" }, + { step: "Apply fix", status: "inProgress" }, + { step: "Fallback title", status: "pending" }, + { step: "Unknown status", status: "pending" }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts new file mode 100644 index 00000000000..4ab36636fb9 --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -0,0 +1,99 @@ +/** + * Public Docs: https://cursor.com/docs/cli/acp#cursor-extension-methods + * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md + */ +import type { UserInputQuestion } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const CursorAskQuestionOption = Schema.Struct({ + id: Schema.String, + label: Schema.String, +}); + +const CursorAskQuestion = Schema.Struct({ + id: Schema.String, + prompt: Schema.String, + options: Schema.Array(CursorAskQuestionOption), + allowMultiple: Schema.optional(Schema.Boolean), +}); + +export const CursorAskQuestionRequest = Schema.Struct({ + toolCallId: Schema.String, + title: Schema.optional(Schema.String), + questions: Schema.Array(CursorAskQuestion), +}); + +const CursorTodoStatus = Schema.String; + +const CursorTodo = Schema.Struct({ + id: Schema.optional(Schema.String), + content: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + status: Schema.optional(CursorTodoStatus), +}); + +const CursorPlanPhase = Schema.Struct({ + name: Schema.String, + todos: Schema.Array(CursorTodo), +}); + +export const CursorCreatePlanRequest = Schema.Struct({ + toolCallId: Schema.String, + name: Schema.optional(Schema.String), + overview: Schema.optional(Schema.String), + plan: Schema.String, + todos: Schema.Array(CursorTodo), + isProject: Schema.optional(Schema.Boolean), + phases: Schema.optional(Schema.Array(CursorPlanPhase)), +}); + +export const CursorUpdateTodosRequest = Schema.Struct({ + toolCallId: Schema.String, + todos: Schema.Array(CursorTodo), + merge: Schema.Boolean, +}); + +export function extractAskQuestions( + params: typeof CursorAskQuestionRequest.Type, +): ReadonlyArray { + return params.questions.map((question) => ({ + id: question.id, + header: "Question", + question: question.prompt, + multiSelect: question.allowMultiple === true, + options: + question.options.length > 0 + ? question.options.map((option) => ({ + label: option.label, + description: option.label, + })) + : [{ label: "OK", description: "Continue" }], + })); +} + +export function extractPlanMarkdown(params: typeof CursorCreatePlanRequest.Type): string { + return params.plan || "# Plan\n\n(Cursor did not supply plan text.)"; +} + +export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type): { + readonly explanation?: string; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; +} { + const plan = params.todos.flatMap((todo) => { + const step = todo.content?.trim() ?? todo.title?.trim() ?? ""; + if (step === "") { + return []; + } + const status: "pending" | "inProgress" | "completed" = + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" || todo.status === "inProgress" + ? "inProgress" + : "pending"; + return [{ step, status }]; + }); + return { plan }; +} diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts new file mode 100644 index 00000000000..89422d865ad --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -0,0 +1,121 @@ +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { describe, expect, it } from "vitest"; + +import { applyCursorAcpModelSelection, buildCursorAcpSpawnInput } from "./CursorAcpSupport.ts"; + +const parameterizedGpt54ConfigOptions: ReadonlyArray = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gpt-5.4-medium-fast", + options: [{ value: "gpt-5.4-medium-fast", name: "GPT-5.4" }], + }, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "extra-high", name: "Extra High" }, + ], + }, + { + id: "context", + name: "Context", + category: "model_config", + type: "select", + currentValue: "272k", + options: [ + { value: "272k", name: "272K" }, + { value: "1m", name: "1M" }, + ], + }, + { + id: "fast", + name: "Fast", + category: "model_config", + type: "select", + currentValue: "false", + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "Fast" }, + ], + }, +]; + +describe("buildCursorAcpSpawnInput", () => { + it("builds the default Cursor ACP command", () => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "agent", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("includes the configured api endpoint when present", () => { + expect( + buildCursorAcpSpawnInput( + { + binaryPath: "/usr/local/bin/agent", + apiEndpoint: "http://localhost:3000", + }, + "/tmp/project", + ), + ).toEqual({ + command: "/usr/local/bin/agent", + args: ["-e", "http://localhost:3000", "acp"], + cwd: "/tmp/project", + }); + }); +}); + +describe("applyCursorAcpModelSelection", () => { + it("sets the base model before applying separate config options", async () => { + const calls: Array< + | { readonly type: "model"; readonly value: string } + | { readonly type: "config"; readonly configId: string; readonly value: string | boolean } + > = []; + + const runtime = { + getConfigOptions: Effect.succeed(parameterizedGpt54ConfigOptions), + setModel: (value: string) => + Effect.sync(() => { + calls.push({ type: "model", value }); + }), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ type: "config", configId, value }); + }), + }; + + await Effect.runPromise( + applyCursorAcpModelSelection({ + runtime, + model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]", + selections: [ + { id: "reasoning", value: "xhigh" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ], + mapError: ({ step, configId, cause }) => + step === "set-config-option" + ? `failed to set config option ${configId}: ${cause.message}` + : `failed to set model: ${cause.message}`, + }), + ); + + expect(calls).toEqual([ + { type: "model", value: "gpt-5.4-medium-fast" }, + { type: "config", configId: "reasoning", value: "extra-high" }, + { type: "config", configId: "context", value: "1m" }, + { type: "config", configId: "fast", value: "true" }, + ]); + }); +}); diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts new file mode 100644 index 00000000000..5893c33215d --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -0,0 +1,113 @@ +import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + resolveCursorAcpBaseModelId, + resolveCursorAcpConfigUpdates, +} from "../Layers/CursorProvider.ts"; +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; + +type CursorAcpRuntimeCursorSettings = Pick; + +export interface CursorAcpRuntimeInput extends Omit< + AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export interface CursorAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option" | "set-model"; + readonly configId?: string; +} + +export function buildCursorAcpSpawnInput( + cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSpawnInput { + return { + command: cursorSettings?.binaryPath || "agent", + args: [ + ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + "acp", + ], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeCursorAcpRuntime = ( + input: CursorAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), + authMethodId: "cursor_login", + clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }); + +interface CursorAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; +} + +export function applyCursorAcpModelSelection(input: { + readonly runtime: CursorAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + return Effect.gen(function* () { + yield* input.runtime.setModel(resolveCursorAcpBaseModelId(input.model)).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-model", + }), + ), + ); + + const configUpdates = resolveCursorAcpConfigUpdates( + yield* input.runtime.getConfigOptions, + input.selections, + ); + for (const update of configUpdates) { + yield* input.runtime.setConfigOption(update.configId, update.value).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-config-option", + configId: update.configId, + }), + ), + ); + } + }); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts new file mode 100644 index 00000000000..5af56dc6b0e --- /dev/null +++ b/apps/server/src/provider/builtInDrivers.ts @@ -0,0 +1,50 @@ +/** + * BUILT_IN_DRIVERS — the static set of `ProviderDriver`s this build ships + * with. + * + * Every driver that the server knows how to instantiate from settings is + * listed here. The `ProviderInstanceRegistry` iterates this array when + * resolving `providerInstances` entries; anything not in the array surfaces + * as an `"unavailable"` shadow snapshot at runtime (see + * `buildUnavailableProviderSnapshot`). + * + * Adding a new first-party driver means: + * 1. implement `ProviderDriver` in a sibling `Drivers/Driver.ts`, + * 2. add it to this array, + * 3. ensure the runtime layer satisfies its declared `R`. + * + * The aggregated `BuiltInDriversEnv` type is the union of every driver's + * env requirement — the registry layer's `R` is this type, and the runtime + * layer (ChildProcessSpawner, FileSystem, Path, ServerConfig, + * OpenCodeRuntime, …) must satisfy it. + * + * @module provider/builtInDrivers + */ +import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; +import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import type { AnyProviderDriver } from "./ProviderDriver.ts"; + +/** + * Union of infrastructure services required to construct any built-in + * driver. The registry layer declares `R = BuiltInDriversEnv`; the runtime + * layer must provide every service in this union. + */ +export type BuiltInDriversEnv = + | ClaudeDriverEnv + | CodexDriverEnv + | CursorDriverEnv + | OpenCodeDriverEnv; + +/** + * Ordered list of built-in drivers. Order matters only for tie-breaking in + * UI presentation — the registry itself is keyed by `driverKind`, so + * iteration order has no functional effect on instance lookup. + */ +export const BUILT_IN_DRIVERS: ReadonlyArray> = [ + CodexDriver, + ClaudeDriver, + CursorDriver, + OpenCodeDriver, +]; diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts new file mode 100644 index 00000000000..559726c65ea --- /dev/null +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -0,0 +1,17 @@ +import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import type * as Stream from "effect/Stream"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; + +export type ProviderSnapshotSource = { + /** + * Routing key — uniquely identifies this instance in the aggregated + * snapshot list. Two different snapshot sources may share the same + * driver kind (multiple instances of the same driver). + */ + readonly instanceId: ProviderInstanceId; + /** Driver implementation kind. */ + readonly driverKind: ProviderDriverKind; + readonly getSnapshot: ServerProviderShape["getSnapshot"]; + readonly refresh: ServerProviderShape["refresh"]; + readonly streamChanges: Stream.Stream; +}; diff --git a/apps/server/src/provider/codexAccount.ts b/apps/server/src/provider/codexAccount.ts deleted file mode 100644 index 1db00250f6d..00000000000 --- a/apps/server/src/provider/codexAccount.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { ServerProviderModel } from "@t3tools/contracts"; - -export type CodexPlanType = - | "free" - | "go" - | "plus" - | "pro" - | "team" - | "business" - | "enterprise" - | "edu" - | "unknown"; - -export interface CodexAccountSnapshot { - readonly type: "apiKey" | "chatgpt" | "unknown"; - readonly planType: CodexPlanType | null; - readonly sparkEnabled: boolean; -} - -export const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; -export const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; -const CODEX_SPARK_ENABLED_PLAN_TYPES = new Set(["pro"]); - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { - const record = asObject(response); - const account = asObject(record?.account) ?? record; - const accountType = asString(account?.type); - - if (accountType === "apiKey") { - return { - type: "apiKey", - planType: null, - sparkEnabled: false, - }; - } - - if (accountType === "chatgpt") { - const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; - return { - type: "chatgpt", - planType, - sparkEnabled: CODEX_SPARK_ENABLED_PLAN_TYPES.has(planType), - }; - } - - return { - type: "unknown", - planType: null, - sparkEnabled: false, - }; -} - -export function codexAuthSubType(account: CodexAccountSnapshot | undefined): string | undefined { - if (account?.type === "apiKey") { - return "apiKey"; - } - - if (account?.type !== "chatgpt") { - return undefined; - } - - return account.planType && account.planType !== "unknown" ? account.planType : "chatgpt"; -} - -export function codexAuthSubLabel(account: CodexAccountSnapshot | undefined): string | undefined { - switch (codexAuthSubType(account)) { - case "apiKey": - return "OpenAI API Key"; - case "chatgpt": - return "ChatGPT Subscription"; - case "free": - return "ChatGPT Free Subscription"; - case "go": - return "ChatGPT Go Subscription"; - case "plus": - return "ChatGPT Plus Subscription"; - case "pro": - return "ChatGPT Pro Subscription"; - case "team": - return "ChatGPT Team Subscription"; - case "business": - return "ChatGPT Business Subscription"; - case "enterprise": - return "ChatGPT Enterprise Subscription"; - case "edu": - return "ChatGPT Edu Subscription"; - default: - return undefined; - } -} - -export function adjustCodexModelsForAccount( - baseModels: ReadonlyArray, - account: CodexAccountSnapshot | undefined, -): ReadonlyArray { - if (account?.sparkEnabled !== false) { - return baseModels; - } - - return baseModels.filter((model) => model.isCustom || model.slug !== CODEX_SPARK_MODEL); -} - -export function resolveCodexModelForAccount( - model: string | undefined, - account: CodexAccountSnapshot, -): string | undefined { - if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) { - return model; - } - - return CODEX_DEFAULT_MODEL; -} diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts deleted file mode 100644 index 7b3c9eeb79f..00000000000 --- a/apps/server/src/provider/codexAppServer.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import readline from "node:readline"; -import type { ServerProviderSkill } from "@t3tools/contracts"; -import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; - -interface JsonRpcProbeResponse { - readonly id?: unknown; - readonly result?: unknown; - readonly error?: { - readonly message?: unknown; - }; -} - -export interface CodexDiscoverySnapshot { - readonly account: CodexAccountSnapshot; - readonly skills: ReadonlyArray; -} - -function readErrorMessage(response: JsonRpcProbeResponse): string | undefined { - return typeof response.error?.message === "string" ? response.error.message : undefined; -} - -function readObject(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - -function readArray(value: unknown): ReadonlyArray | undefined { - return Array.isArray(value) ? value : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function nonEmptyTrimmed(value: unknown): string | undefined { - const candidate = readString(value)?.trim(); - return candidate ? candidate : undefined; -} - -function parseCodexSkillsResult(result: unknown, cwd: string): ReadonlyArray { - const resultRecord = readObject(result); - const dataBuckets = readArray(resultRecord?.data) ?? []; - const matchingBucket = dataBuckets.find( - (value) => nonEmptyTrimmed(readObject(value)?.cwd) === cwd, - ); - const rawSkills = - readArray(readObject(matchingBucket)?.skills) ?? readArray(resultRecord?.skills) ?? []; - - return rawSkills.flatMap((value) => { - const skill = readObject(value); - const display = readObject(skill?.interface); - const name = nonEmptyTrimmed(skill?.name); - const path = nonEmptyTrimmed(skill?.path); - if (!name || !path) { - return []; - } - - return [ - { - name, - path, - enabled: skill?.enabled !== false, - ...(nonEmptyTrimmed(skill?.description) - ? { description: nonEmptyTrimmed(skill?.description) } - : {}), - ...(nonEmptyTrimmed(skill?.scope) ? { scope: nonEmptyTrimmed(skill?.scope) } : {}), - ...(nonEmptyTrimmed(display?.displayName) - ? { displayName: nonEmptyTrimmed(display?.displayName) } - : {}), - ...(nonEmptyTrimmed(skill?.shortDescription) || nonEmptyTrimmed(display?.shortDescription) - ? { - shortDescription: - nonEmptyTrimmed(skill?.shortDescription) ?? - nonEmptyTrimmed(display?.shortDescription), - } - : {}), - } satisfies ServerProviderSkill, - ]; - }); -} - -export function buildCodexInitializeParams() { - return { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - } as const; -} - -export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): void { - if (process.platform === "win32" && child.pid !== undefined) { - try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); - return; - } catch { - // Fall through to direct kill when taskkill is unavailable. - } - } - - child.kill(); -} - -export async function probeCodexDiscovery(input: { - readonly binaryPath: string; - readonly homePath?: string; - readonly cwd: string; - readonly signal?: AbortSignal; -}): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(input.binaryPath, ["app-server"], { - env: { - ...process.env, - ...(input.homePath ? { CODEX_HOME: input.homePath } : {}), - }, - stdio: ["pipe", "pipe", "pipe"], - shell: process.platform === "win32", - }); - const output = readline.createInterface({ input: child.stdout }); - - let completed = false; - let account: CodexAccountSnapshot | undefined; - let skills: ReadonlyArray | undefined; - - const cleanup = () => { - output.removeAllListeners(); - output.close(); - child.removeAllListeners(); - if (!child.killed) { - killCodexChildProcess(child); - } - }; - - const finish = (callback: () => void) => { - if (completed) return; - completed = true; - cleanup(); - callback(); - }; - - const fail = (error: unknown) => - finish(() => - reject( - error instanceof Error - ? error - : new Error(`Codex discovery probe failed: ${String(error)}.`), - ), - ); - - const maybeResolve = () => { - if (account && skills !== undefined) { - const resolvedAccount = account; - const resolvedSkills = skills; - finish(() => resolve({ account: resolvedAccount, skills: resolvedSkills })); - } - }; - - if (input.signal?.aborted) { - fail(new Error("Codex discovery probe aborted.")); - return; - } - input.signal?.addEventListener("abort", () => - fail(new Error("Codex discovery probe aborted.")), - ); - - const writeMessage = (message: unknown) => { - if (!child.stdin.writable) { - fail(new Error("Cannot write to codex app-server stdin.")); - return; - } - - child.stdin.write(`${JSON.stringify(message)}\n`); - }; - - output.on("line", (line) => { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - fail(new Error("Received invalid JSON from codex app-server during discovery probe.")); - return; - } - - if (!parsed || typeof parsed !== "object") { - return; - } - - const response = parsed as JsonRpcProbeResponse; - if (response.id === 1) { - const errorMessage = readErrorMessage(response); - if (errorMessage) { - fail(new Error(`initialize failed: ${errorMessage}`)); - return; - } - - writeMessage({ method: "initialized" }); - writeMessage({ id: 2, method: "skills/list", params: { cwds: [input.cwd] } }); - writeMessage({ id: 3, method: "account/read", params: {} }); - return; - } - - if (response.id === 2) { - const errorMessage = readErrorMessage(response); - skills = errorMessage ? [] : parseCodexSkillsResult(response.result, input.cwd); - maybeResolve(); - return; - } - - if (response.id === 3) { - const errorMessage = readErrorMessage(response); - if (errorMessage) { - fail(new Error(`account/read failed: ${errorMessage}`)); - return; - } - - account = readCodexAccountSnapshot(response.result); - maybeResolve(); - } - }); - - child.once("error", fail); - child.once("exit", (code, signal) => { - if (completed) return; - fail( - new Error( - `codex app-server exited before probe completed (code=${code ?? "null"}, signal=${signal ?? "null"}).`, - ), - ); - }); - - writeMessage({ - id: 1, - method: "initialize", - params: buildCodexInitializeParams(), - }); - }); -} diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts deleted file mode 100644 index 544020016c6..00000000000 --- a/apps/server/src/provider/codexCliVersion.ts +++ /dev/null @@ -1,141 +0,0 @@ -const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; - -export const MINIMUM_CODEX_CLI_VERSION = "0.37.0"; - -interface ParsedSemver { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray; -} - -function normalizeCodexVersion(version: string): string { - const [main, prerelease] = version.trim().split("-", 2); - const segments = (main ?? "") - .split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0); - - if (segments.length === 2) { - segments.push("0"); - } - - return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); -} - -function parseSemver(version: string): ParsedSemver | null { - const normalized = normalizeCodexVersion(version); - const [main = "", prerelease] = normalized.split("-", 2); - const segments = main.split("."); - if (segments.length !== 3) { - return null; - } - - const [majorSegment, minorSegment, patchSegment] = segments; - if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { - return null; - } - - const major = Number.parseInt(majorSegment, 10); - const minor = Number.parseInt(minorSegment, 10); - const patch = Number.parseInt(patchSegment, 10); - if (![major, minor, patch].every(Number.isInteger)) { - return null; - } - - return { - major, - minor, - patch, - prerelease: - prerelease - ?.split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) ?? [], - }; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftNumeric = /^\d+$/.test(left); - const rightNumeric = /^\d+$/.test(right); - - if (leftNumeric && rightNumeric) { - return Number.parseInt(left, 10) - Number.parseInt(right, 10); - } - if (leftNumeric) { - return -1; - } - if (rightNumeric) { - return 1; - } - return left.localeCompare(right); -} - -export function compareCodexCliVersions(left: string, right: string): number { - const parsedLeft = parseSemver(left); - const parsedRight = parseSemver(right); - if (!parsedLeft || !parsedRight) { - return left.localeCompare(right); - } - - if (parsedLeft.major !== parsedRight.major) { - return parsedLeft.major - parsedRight.major; - } - if (parsedLeft.minor !== parsedRight.minor) { - return parsedLeft.minor - parsedRight.minor; - } - if (parsedLeft.patch !== parsedRight.patch) { - return parsedLeft.patch - parsedRight.patch; - } - - if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { - return 0; - } - if (parsedLeft.prerelease.length === 0) { - return 1; - } - if (parsedRight.prerelease.length === 0) { - return -1; - } - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (comparison !== 0) { - return comparison; - } - } - - return 0; -} - -export function parseCodexCliVersion(output: string): string | null { - const match = CODEX_VERSION_PATTERN.exec(output); - if (!match?.[1]) { - return null; - } - - const parsed = parseSemver(match[1]); - if (!parsed) { - return null; - } - - return normalizeCodexVersion(match[1]); -} - -export function isCodexCliVersionSupported(version: string): boolean { - return compareCodexCliVersions(version, MINIMUM_CODEX_CLI_VERSION) >= 0; -} - -export function formatCodexCliUpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Codex CLI ${versionLabel} is too old for T3 Code. Upgrade to v${MINIMUM_CODEX_CLI_VERSION} or newer and restart T3 Code.`; -} diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts new file mode 100644 index 00000000000..1f3ebeab089 --- /dev/null +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -0,0 +1,288 @@ +import { describe, it, assert } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; + +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const fastModeCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + ], +}); + +interface TestSettings { + readonly enabled: boolean; +} + +const maintenanceCapabilities = { + provider: ProviderDriverKind.make("codex"), + packageName: "@openai/codex", + update: { + command: "npm install -g @openai/codex@latest", + + executable: "npm", + + args: ["install", "-g", "@openai/codex@latest"], + + lockKey: "npm-global", + }, +} as const; + +const initialSnapshot: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-04-10T00:00:00.000Z", + message: "Checking provider availability...", + models: [], + slashCommands: [], + skills: [], +}; + +const refreshedSnapshot: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:01.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +const enrichedSnapshot: ServerProvider = { + ...refreshedSnapshot, + checkedAt: "2026-04-10T00:00:02.000Z", + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: fastModeCapabilities, + }, + ], +}; + +const refreshedSnapshotSecond: ServerProvider = { + ...refreshedSnapshot, + checkedAt: "2026-04-10T00:00:03.000Z", + message: "Refreshed provider availability again.", +}; + +const enrichedSnapshotSecond: ServerProvider = { + ...refreshedSnapshotSecond, + checkedAt: "2026-04-10T00:00:04.000Z", + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], +}; + +describe("makeManagedServerProvider", () => { + it.effect( + "runs the initial provider check in the background and streams the refreshed snapshot", + () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const releaseCheck = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Deferred.await(releaseCheck)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); + + const initial = yield* provider.getSnapshot; + assert.deepStrictEqual(initial, initialSnapshot); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Deferred.succeed(releaseCheck, undefined); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [refreshedSnapshot]); + assert.deepStrictEqual(latest, refreshedSnapshot); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ), + ); + + it.effect("reruns the provider check when streamed settings change", () => + Effect.scoped( + Effect.gen(function* () { + const settingsRef = yield* Ref.make({ enabled: true }); + const settingsChanges = yield* PubSub.unbounded(); + const checkCalls = yield* Ref.make(0); + const releaseInitialCheck = yield* Deferred.make(); + const releaseSettingsCheck = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Ref.get(settingsRef), + streamSettings: Stream.fromPubSub(settingsChanges), + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) + : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)), + ), + ), + refreshInterval: "1 hour", + }); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Deferred.succeed(releaseInitialCheck, undefined); + yield* Ref.set(settingsRef, { enabled: false }); + yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Deferred.succeed(releaseSettingsCheck, undefined); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [refreshedSnapshot, refreshedSnapshotSecond]); + assert.deepStrictEqual(latest, refreshedSnapshotSecond); + assert.strictEqual(yield* Ref.get(checkCalls), 2); + }), + ), + ); + + it.effect("streams supplemental snapshot updates after the base provider check completes", () => + Effect.scoped( + Effect.gen(function* () { + const releaseEnrichment = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)), + enrichSnapshot: ({ publishSnapshot }) => + Deferred.await(releaseEnrichment).pipe( + Effect.flatMap(() => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: "1 hour", + }); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Deferred.succeed(releaseCheck, undefined); + + yield* Deferred.succeed(releaseEnrichment, undefined); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); + assert.deepStrictEqual(latest, enrichedSnapshot); + }), + ), + ); + + it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => + Effect.scoped( + Effect.gen(function* () { + const publishCallbacks: Array<(snapshot: ServerProvider) => Effect.Effect> = []; + const refreshCount = yield* Ref.make(0); + const firstCallbackReady = yield* Deferred.make(); + const secondCallbackReady = yield* Deferred.make(); + const allowFirstRefresh = yield* Deferred.make(); + const provider = 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(refreshCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot)) + : Effect.succeed(refreshedSnapshotSecond), + ), + ), + enrichSnapshot: ({ publishSnapshot }) => + Effect.gen(function* () { + publishCallbacks.push(publishSnapshot); + if (publishCallbacks.length === 1) { + yield* Deferred.succeed(firstCallbackReady, undefined).pipe(Effect.ignore); + } else if (publishCallbacks.length === 2) { + yield* Deferred.succeed(secondCallbackReady, undefined).pipe(Effect.ignore); + } + }), + refreshInterval: "1 hour", + }); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Deferred.succeed(allowFirstRefresh, undefined); + yield* Deferred.await(firstCallbackReady); + + yield* provider.refresh; + yield* Deferred.await(secondCallbackReady); + + yield* publishCallbacks[0]!(enrichedSnapshot); + yield* publishCallbacks[1]!(enrichedSnapshotSecond); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [ + refreshedSnapshot, + refreshedSnapshotSecond, + enrichedSnapshotSecond, + ]); + assert.deepStrictEqual(latest, enrichedSnapshotSecond); + }), + ), + ); +}); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 59aeac1ab5f..2f07c5d508c 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,17 +1,37 @@ import type { ServerProvider } from "@t3tools/contracts"; -import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; +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 Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import type { ServerProviderShape } from "./Services/ServerProvider"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; import { ServerSettingsError } from "@t3tools/contracts"; +interface ProviderSnapshotState { + readonly snapshot: ServerProvider; + readonly enrichmentGeneration: number; +} + export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { + readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"]; readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; + readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly enrichSnapshot?: (input: { + readonly settings: Settings; + readonly snapshot: ServerProvider; + readonly getSnapshot: Effect.Effect; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + }) => Effect.Effect; readonly refreshInterval?: Duration.Input; }): Effect.fn.Return { const refreshSemaphore = yield* Semaphore.make(1); @@ -20,9 +40,62 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( PubSub.shutdown, ); const initialSettings = yield* input.getSettings; - const initialSnapshot = yield* input.checkProvider; - const snapshotRef = yield* Ref.make(initialSnapshot); + const initialSnapshot = yield* input.initialSnapshot(initialSettings); + const snapshotStateRef = yield* Ref.make({ + snapshot: initialSnapshot, + enrichmentGeneration: 0, + }); const settingsRef = yield* Ref.make(initialSettings); + const enrichmentFiberRef = yield* Ref.make | null>(null); + const scope = yield* Effect.scope; + + const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( + generation: number, + nextSnapshot: ServerProvider, + ) { + const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { + if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) { + return [null, state] as const; + } + return [ + nextSnapshot, + { + ...state, + snapshot: nextSnapshot, + }, + ] as const; + }); + if (snapshotToPublish === null) { + return; + } + yield* PubSub.publish(changesPubSub, snapshotToPublish); + }); + + const restartSnapshotEnrichment = Effect.fn("restartSnapshotEnrichment")(function* ( + settings: Settings, + snapshot: ServerProvider, + generation: number, + ) { + const previousFiber = yield* Ref.getAndSet(enrichmentFiberRef, null); + if (previousFiber) { + yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore); + } + + if (!input.enrichSnapshot) { + return; + } + + const fiber = yield* input + .enrichSnapshot({ + settings, + snapshot, + getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), + publishSnapshot: (nextSnapshot) => publishEnrichedSnapshot(generation, nextSnapshot), + }) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); + + yield* Ref.set(enrichmentFiberRef, fiber); + }); const applySnapshotBase = Effect.fn("applySnapshot")(function* ( nextSettings: Settings, @@ -32,13 +105,25 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const previousSettings = yield* Ref.get(settingsRef); if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { yield* Ref.set(settingsRef, nextSettings); - return yield* Ref.get(snapshotRef); + return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); } const nextSnapshot = yield* input.checkProvider; + const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { + const generation = input.enrichSnapshot + ? state.enrichmentGeneration + 1 + : state.enrichmentGeneration; + return [ + generation, + { + snapshot: nextSnapshot, + enrichmentGeneration: generation, + }, + ] as const; + }); yield* Ref.set(settingsRef, nextSettings); - yield* Ref.set(snapshotRef, nextSnapshot); yield* PubSub.publish(changesPubSub, nextSnapshot); + yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); return nextSnapshot; }); const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => @@ -60,7 +145,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), ).pipe(Effect.forkScoped); + yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + return { + maintenanceCapabilities: input.maintenanceCapabilities, getSnapshot: input.getSettings.pipe( Effect.flatMap(applySnapshot), Effect.tapError(Effect.logError), diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts new file mode 100644 index 00000000000..ddeb9f26431 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -0,0 +1,552 @@ +import { pathToFileURL } from "node:url"; + +import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; +import { + createOpencodeClient, + type Agent, + type FilePartInput, + type OpencodeClient, + type PermissionRuleset, + type ProviderListResponse, + type QuestionAnswer, + type QuestionRequest, +} from "@opencode-ai/sdk/v2"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; +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 P from "effect/Predicate"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { collectStreamAsString } from "./providerSnapshot.ts"; +import * as NetService from "@t3tools/shared/Net"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; + +const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; +export interface OpenCodeServerProcess { + readonly url: string; + readonly exitCode: Effect.Effect; +} + +export interface OpenCodeServerConnection { + readonly url: string; + readonly exitCode: Effect.Effect | null; + readonly external: boolean; +} + +const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; +export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ + readonly operation: string; + readonly cause?: unknown; + readonly detail: string; +}> { + static readonly is = (u: unknown): u is OpenCodeRuntimeError => + P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); +} + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export function openCodeRuntimeErrorDetail(cause: unknown): string { + if (OpenCodeRuntimeError.is(cause)) return cause.detail; + if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); + if (cause && typeof cause === "object") { + // SDK v2 throws { response, request, error? } shapes — extract what's useful + const anyCause = cause as Record; + const status = (anyCause.response as { status?: number } | undefined)?.status; + const body = anyCause.error ?? anyCause.data ?? anyCause.body; + const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); + if (encodedBody) { + return `status=${status ?? "?"} body=${encodedBody}`; + } + } + return String(cause); +} + +export const runOpenCodeSdk = ( + operation: string, + fn: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: (cause) => + new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), + }).pipe(Effect.withSpan(`opencode.${operation}`)); + +export interface OpenCodeCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenCodeInventory { + readonly providerList: ProviderListResponse; + readonly agents: ReadonlyArray; +} + +export interface ParsedOpenCodeModelSlug { + readonly providerID: string; + readonly modelID: string; +} + +export interface OpenCodeRuntimeShape { + /** + * Spawns a local OpenCode server process. Its lifetime is bound to the caller's + * `Scope.Scope` — the child is killed automatically when that scope closes. + * Consumers that want a long-lived server must create and hold a scope explicitly + * (see {@link Scope.make}) and close it when done. + */ + readonly startOpenCodeServerProcess: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + /** + * Returns a handle to either an externally-managed OpenCode server (when + * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a + * freshly spawned local server whose lifetime is bound to the caller's scope. + */ + readonly connectToOpenCodeServer: (input: { + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly environment?: NodeJS.ProcessEnv; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly runOpenCodeCommand: (input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; + readonly createOpenCodeSdkClient: (input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; + }) => OpencodeClient; + readonly loadOpenCodeInventory: ( + client: OpencodeClient, + ) => Effect.Effect; +} + +function parseServerUrlFromOutput(output: string): string | null { + for (const line of output.split("\n")) { + if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { + continue; + } + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + return match?.[1] ?? null; + } + return null; +} + +export function parseOpenCodeModelSlug( + slug: string | null | undefined, +): ParsedOpenCodeModelSlug | null { + if (typeof slug !== "string") { + return null; + } + + const trimmed = slug.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator === trimmed.length - 1) { + return null; + } + + return { + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), + }; +} + +export function openCodeQuestionId( + index: number, + question: QuestionRequest["questions"][number], +): string { + const header = question.header + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-"); + return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; +} + +export function toOpenCodeFileParts(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): Array { + const parts: Array = []; + + for (const attachment of input.attachments ?? []) { + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) { + continue; + } + + parts.push({ + type: "file", + mime: attachment.mimeType, + filename: attachment.name, + url: pathToFileURL(attachmentPath).href, + }); + } + + return parts; +} + +export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { + if (runtimeMode === "full-access") { + return [{ permission: "*", pattern: "*", action: "allow" }]; + } + + return [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + ]; +} + +export function toOpenCodePermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + return "always"; + case "decline": + case "cancel": + default: + return "reject"; + } +} + +export function toOpenCodeQuestionAnswers( + request: QuestionRequest, + answers: Record, +): Array { + return request.questions.map((question, index) => { + const raw = + answers[openCodeQuestionId(index, question)] ?? + answers[question.header] ?? + answers[question.question]; + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === "string"); + } + if (typeof raw === "string") { + return raw.trim().length > 0 ? [raw] : []; + } + return []; + }); +} + +function ensureRuntimeError( + operation: OpenCodeRuntimeError["operation"], + detail: string, + cause: unknown, +): OpenCodeRuntimeError { + return OpenCodeRuntimeError.is(cause) + ? cause + : new OpenCodeRuntimeError({ operation, detail, cause }); +} + +const makeOpenCodeRuntime = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const netService = yield* NetService.NetService; + + const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(input.binaryPath, [...input.args], { + shell: process.platform === "win32", + env: input.environment ?? process.env, + }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ); + const exitCode = Number(code); + if (isWindowsCommandNotFound(exitCode, stderr)) { + return yield* new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: `spawn ${input.binaryPath} ENOENT`, + }); + } + return { + stdout, + stderr, + code: exitCode, + } satisfies OpenCodeCommandResult; + }).pipe( + Effect.scoped, + Effect.mapError((cause) => + ensureRuntimeError( + "runOpenCodeCommand", + `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ), + ), + ); + + const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => + Effect.gen(function* () { + // Bind this server's lifetime to the caller's scope. When the caller's + // scope closes, the spawned child is killed and all associated fibers + // are interrupted automatically — no `close()` method needed. + const runtimeScope = yield* Scope.Scope; + + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = + input.port ?? + (yield* netService.findAvailablePort(0).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), + ), + )); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + + const child = yield* spawner + .spawn( + ChildProcess.make(input.binaryPath, args, { + detached: process.platform !== "win32", + shell: process.platform === "win32", + env: { + ...(input.environment ?? process.env), + OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, + }, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), + ), + ); + + const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => + process.platform === "win32" + ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + process.kill(-Number(child.pid), signal); + } catch { + // The direct child may already have exited after starting the + // server; the process group kill is best-effort cleanup for + // any serve process left in that group. + } + }); + const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), + Effect.ignore, + ); + yield* Scope.addFinalizer(runtimeScope, terminateChild); + + const stdoutRef = yield* Ref.make(""); + const stderrRef = yield* Ref.make(""); + const readyDeferred = yield* Deferred.make(); + + const setReadyFromStdoutChunk = (chunk: string) => + Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( + Effect.flatMap((nextStdout) => { + const parsed = parseServerUrlFromOutput(nextStdout); + return parsed + ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) + : Effect.void; + }), + ); + + const stdoutFiber = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach(setReadyFromStdoutChunk), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + const stderrFiber = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + + const exitFiber = yield* child.exitCode.pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + const stdout = yield* Ref.get(stdoutRef); + const stderr = yield* Ref.get(stderrRef); + const exitCode = Number(code); + yield* Deferred.fail( + readyDeferred, + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: [ + `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + cause: { exitCode, stdout, stderr }, + }), + ).pipe(Effect.ignore); + }), + ), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + + const readyExit = yield* Effect.exit( + Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), + ); + + // Startup-time fibers are no longer needed once ready has resolved (either + // way). The exit fiber is only interrupted on failure; on success it keeps + // the caller's `exitCode` effect observable until the scope closes. + yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); + yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); + + if (Exit.isFailure(readyExit)) { + yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + const squashed = Cause.squash(readyExit.cause); + return yield* ensureRuntimeError( + "startOpenCodeServerProcess", + `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, + squashed, + ); + } + + const readyOption = readyExit.value; + if (Option.isNone(readyOption)) { + yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + return yield* new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + }); + } + + return { + url: readyOption.value, + exitCode: child.exitCode.pipe( + Effect.map(Number), + Effect.orElseSucceed(() => 0), + ), + } satisfies OpenCodeServerProcess; + }); + + const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl) { + // We don't own externally-configured servers — no scope interaction. + return Effect.succeed({ + url: serverUrl, + exitCode: null, + external: true, + }); + } + + return startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }).pipe( + Effect.map((server) => ({ + url: server.url, + exitCode: server.exitCode, + external: false, + })), + ); + }; + + const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => + createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + throwOnError: true, + }); + + const loadProviders = (client: OpencodeClient) => + runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( + Effect.filterMapOrFail( + (list) => + list.data + ? Result.succeed(list.data) + : Result.fail( + new OpenCodeRuntimeError({ + operation: "provider.list", + detail: "OpenCode provider list was empty.", + }), + ), + (result) => result, + ), + ); + + const loadAgents = (client: OpencodeClient) => + runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( + Effect.map((result) => result.data ?? []), + ); + + const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => + Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe( + Effect.map(([providerList, agents]) => ({ providerList, agents })), + ); + + return { + startOpenCodeServerProcess, + connectToOpenCodeServer, + runOpenCodeCommand, + createOpenCodeSdkClient, + loadOpenCodeInventory, + } satisfies OpenCodeRuntimeShape; +}); + +export class OpenCodeRuntime extends Context.Service()( + "t3/provider/OpenCodeRuntime", +) {} + +export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( + Layer.provide(NetService.layer), +); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts new file mode 100644 index 00000000000..e032b87a4d0 --- /dev/null +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -0,0 +1,489 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import os from "node:os"; +import path from "node:path"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Random from "effect/Random"; +import { + clearLatestProviderVersionCacheForTests, + createProviderVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "./providerMaintenance.ts"; + +const driver = (value: string) => ProviderDriverKind.make(value); +const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) { + const id = yield* Random.nextUUIDv4; + return path.join(os.tmpdir(), `${name}-${id}`); +}); +const isNativeTestCommandPath = + (expectedPathSegment: string) => + (commandPath: string): boolean => + normalizeCommandPath(commandPath).includes(expectedPathSegment); +const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + homebrewFormula: "package-tool", + nativeUpdate: null, +}); +const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("nativePackageTool"), + npmPackageName: "@example/native-package-tool", + homebrewFormula: "native-package-tool", + nativeUpdate: { + executable: "native-package-tool", + args: ["update"], + lockKey: "native-package-tool-native", + isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), + }, +}); +const scopedPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("scopedPackageTool"), + npmPackageName: "@example/scoped-package-tool", + homebrewFormula: "example/tap/scoped-package-tool", + nativeUpdate: { + executable: "scoped-package-tool", + args: ["upgrade"], + lockKey: "scoped-package-tool-native", + isCommandPath: isNativeTestCommandPath("/.scoped-package-tool/bin/scoped-package-tool"), + }, +}); +const staticToolUpdate = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: driver("staticTool"), + packageName: null, + updateExecutable: "static-tool", + updateArgs: ["update"], + updateLockKey: "static-tool", + }), +); + +afterEach(() => { + clearLatestProviderVersionCacheForTests(); +}); + +describe("providerMaintenance", () => { + it("marks providers with unknown current versions as unknown", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("packageTool"), + currentVersion: null, + latestVersion: "9.9.9", + }), + ).toMatchObject({ + status: "unknown", + currentVersion: null, + latestVersion: "9.9.9", + }); + }); + + it("marks providers with unknown latest versions as unknown", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("packageTool"), + currentVersion: "1.0.0", + latestVersion: null, + }), + ).toMatchObject({ + status: "unknown", + currentVersion: "1.0.0", + latestVersion: null, + message: null, + }); + }); + + it("marks installed providers behind latest when a newer provider version is available", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("nativePackageTool"), + currentVersion: "2.1.110", + latestVersion: "2.1.117", + maintenanceCapabilities: nativePackageToolUpdate.resolve(), + }), + ).toMatchObject({ + status: "behind_latest", + currentVersion: "2.1.110", + latestVersion: "2.1.117", + updateCommand: "npm install -g @example/native-package-tool@latest", + canUpdate: true, + message: "Install the update now or review provider settings.", + }); + }); + + it("keeps update commands owned by provider maintenance capabilities", () => { + expect(staticToolUpdate.resolve()).toEqual({ + provider: driver("staticTool"), + packageName: null, + update: { + command: "static-tool update", + + executable: "static-tool", + + args: ["update"], + + lockKey: "static-tool", + }, + }); + }); + + it.effect( + "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-vite-plus-capabilities"); + const vitePlusBinDir = path.join(tempDir, ".vite-plus", "bin"); + mkdirSync(vitePlusBinDir, { recursive: true }); + const packageToolPath = path.join(vitePlusBinDir, "package-tool"); + writeFileSync(packageToolPath, "#!/bin/sh\n"); + chmodSync(packageToolPath, 0o755); + + expect( + packageToolUpdate.resolve({ + binaryPath: "package-tool", + platform: "darwin", + env: { + PATH: vitePlusBinDir, + }, + }), + ).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "vp i -g @example/package-tool", + + executable: "vp", + + args: ["i", "-g", "@example/package-tool"], + + lockKey: "vite-plus-global", + }, + }); + }), + ); + + it.effect( + "switches package-managed providers to bun updates when the resolved binary lives in bun's global bin", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-bun-capabilities"); + const bunBinDir = path.join(tempDir, ".bun", "bin"); + mkdirSync(bunBinDir, { recursive: true }); + writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); + + expect( + nativePackageToolUpdate.resolve({ + binaryPath: "native-package-tool", + platform: "win32", + env: { + PATH: bunBinDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + update: { + command: "bun i -g @example/native-package-tool@latest", + + executable: "bun", + + args: ["i", "-g", "@example/native-package-tool@latest"], + + lockKey: "bun-global", + }, + }); + }), + ); + + it.effect( + "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-pnpm-capabilities"); + const pnpmHomeDir = path.join(tempDir, ".local", "share", "pnpm"); + mkdirSync(pnpmHomeDir, { recursive: true }); + const scopedPackageToolPath = path.join(pnpmHomeDir, "scoped-package-tool"); + writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + chmodSync(scopedPackageToolPath, 0o755); + + expect( + scopedPackageToolUpdate.resolve({ + binaryPath: "scoped-package-tool", + platform: "darwin", + env: { + PATH: pnpmHomeDir, + }, + }), + ).toEqual({ + provider: driver("scopedPackageTool"), + packageName: "@example/scoped-package-tool", + update: { + command: "pnpm add -g @example/scoped-package-tool@latest", + + executable: "pnpm", + + args: ["add", "-g", "@example/scoped-package-tool@latest"], + + lockKey: "pnpm-global", + }, + }); + }), + ); + + it("switches package-tool to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + packageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/package-tool", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "brew upgrade package-tool", + + executable: "brew", + + args: ["upgrade", "package-tool"], + + lockKey: "homebrew", + }, + }); + }); + + it.effect( + "switches native-package-tool to native updates when the binary resolves through the native installer", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities"); + const nativeBinDir = path.join(tempDir, ".local", "bin"); + mkdirSync(nativeBinDir, { recursive: true }); + const nativePackageToolPath = path.join(nativeBinDir, "native-package-tool"); + writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); + chmodSync(nativePackageToolPath, 0o755); + + expect( + nativePackageToolUpdate.resolve({ + binaryPath: "native-package-tool", + platform: "darwin", + env: { + PATH: nativeBinDir, + }, + }), + ).toEqual({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + update: { + command: "native-package-tool update", + + executable: "native-package-tool", + + args: ["update"], + + lockKey: "native-package-tool-native", + }, + }); + }), + ); + + it.effect( + "switches scoped-package-tool to native upgrades when the binary resolves through the standalone installer", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities"); + const nativeBinDir = path.join(tempDir, ".scoped-package-tool", "bin"); + mkdirSync(nativeBinDir, { recursive: true }); + const scopedPackageToolPath = path.join(nativeBinDir, "scoped-package-tool"); + writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + chmodSync(scopedPackageToolPath, 0o755); + + expect( + scopedPackageToolUpdate.resolve({ + binaryPath: "scoped-package-tool", + platform: "darwin", + env: { + PATH: nativeBinDir, + }, + }), + ).toEqual({ + provider: driver("scopedPackageTool"), + packageName: "@example/scoped-package-tool", + update: { + command: "scoped-package-tool upgrade", + + executable: "scoped-package-tool", + + args: ["upgrade"], + + lockKey: "scoped-package-tool-native", + }, + }); + }), + ); + + it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + nativePackageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/native-package-tool", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + update: { + command: "brew upgrade native-package-tool", + + executable: "brew", + + args: ["upgrade", "native-package-tool"], + + lockKey: "homebrew", + }, + }); + }); + + it("switches scoped-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + scopedPackageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/scoped-package-tool", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("scopedPackageTool"), + packageName: "@example/scoped-package-tool", + update: { + command: "brew upgrade example/tap/scoped-package-tool", + + executable: "brew", + + args: ["upgrade", "example/tap/scoped-package-tool"], + + lockKey: "homebrew", + }, + }); + }); + + it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-capabilities"); + const binDir = path.join(tempDir, "bin"); + const packageBinDir = path.join( + tempDir, + "lib", + "node_modules", + "@example", + "package-tool", + "bin", + ); + mkdirSync(binDir, { recursive: true }); + mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = path.join(packageBinDir, "package-tool.js"); + const symlinkPath = path.join(binDir, "package-tool"); + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + chmodSync(packageBinPath, 0o755); + symlinkSync(packageBinPath, symlinkPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: symlinkPath, + platform: "darwin", + env: { + PATH: "", + }, + }).pipe(Effect.provide(NodeServices.layer)); + + expect(capabilities).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "npm install -g @example/package-tool@latest", + + executable: "npm", + + args: ["install", "-g", "@example/package-tool@latest"], + + lockKey: "npm-global", + }, + }); + }), + ); + + it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities"); + const binDir = path.join(tempDir, "bin"); + const packageBinDir = path.join( + tempDir, + ".local", + "share", + "pnpm", + "global", + "5", + "node_modules", + "@example", + "package-tool", + "bin", + ); + mkdirSync(binDir, { recursive: true }); + mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = path.join(packageBinDir, "package-tool.js"); + const symlinkPath = path.join(binDir, "package-tool"); + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + chmodSync(packageBinPath, 0o755); + symlinkSync(packageBinPath, symlinkPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: symlinkPath, + platform: "darwin", + env: { + PATH: "", + }, + }).pipe(Effect.provide(NodeServices.layer)); + + expect(capabilities).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "pnpm add -g @example/package-tool@latest", + + executable: "pnpm", + + args: ["add", "-g", "@example/package-tool@latest"], + + lockKey: "pnpm-global", + }, + }); + }), + ); + + it("disables one-click updates for explicit custom binary paths it cannot safely map", () => { + expect( + packageToolUpdate.resolve({ + binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", + platform: "win32", + env: { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: null, + }); + }); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts new file mode 100644 index 00000000000..7f5e9d94dc5 --- /dev/null +++ b/apps/server/src/provider/providerMaintenance.ts @@ -0,0 +1,475 @@ +import { + ProviderDriverKind, + type ServerProvider, + type ServerProviderVersionAdvisory, +} from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; +const LATEST_VERSION_TIMEOUT_MS = 4_000; +const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; + +export interface ProviderMaintenanceCapabilities { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; + readonly update: ProviderMaintenanceCommandAction | null; +} + +export interface ProviderMaintenanceCommandAction { + readonly command: string; + readonly executable: string; + readonly args: ReadonlyArray; + readonly lockKey: string; +} + +export interface ProviderMaintenanceCapabilityResolutionOptions { + readonly binaryPath?: string | null; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly realCommandPath?: string | null; +} + +export interface ProviderMaintenanceCapabilitiesResolver { + readonly resolve: ( + options?: ProviderMaintenanceCapabilityResolutionOptions, + ) => ProviderMaintenanceCapabilities; +} + +export interface PackageManagedProviderMaintenanceDefinition { + readonly provider: ProviderDriverKind; + readonly npmPackageName: string; + readonly homebrewFormula: string | null; + readonly nativeUpdate: { + readonly executable: string; + readonly args: ReadonlyArray; + readonly lockKey: string; + readonly isCommandPath: (commandPath: string) => boolean; + } | null; +} + +interface LatestVersionCacheEntry { + readonly expiresAt: number; + readonly version: string | null; +} + +const latestVersionCache = new Map(); +const NpmLatestVersionResponse = Schema.Struct({ + version: Schema.optional(Schema.String), +}); + +export function clearLatestProviderVersionCacheForTests(): void { + latestVersionCache.clear(); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export function makeProviderMaintenanceCapabilities(input: { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; + readonly updateExecutable: string | null; + readonly updateArgs: ReadonlyArray; + readonly updateLockKey: string | null; +}): ProviderMaintenanceCapabilities { + const update = + input.updateExecutable === null || input.updateLockKey === null + ? null + : { + command: [input.updateExecutable, ...input.updateArgs].join(" "), + executable: input.updateExecutable, + args: input.updateArgs, + lockKey: input.updateLockKey, + }; + return { + provider: input.provider, + packageName: input.packageName, + update, + }; +} + +export function makeManualOnlyProviderMaintenanceCapabilities(input: { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; +}): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: input.provider, + packageName: input.packageName, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); +} + +function makeNpmGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "npm", + updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`], + updateLockKey: "npm-global", + }); +} + +function makeBunGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "bun", + updateArgs: ["i", "-g", `${definition.npmPackageName}@latest`], + updateLockKey: "bun-global", + }); +} + +function makePnpmGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "pnpm", + updateArgs: ["add", "-g", `${definition.npmPackageName}@latest`], + updateLockKey: "pnpm-global", + }); +} + +function makeVitePlusGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "vp", + updateArgs: ["i", "-g", definition.npmPackageName], + updateLockKey: "vite-plus-global", + }); +} + +function makeHomebrewProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + if (!definition.homebrewFormula) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); + } + + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "brew", + updateArgs: ["upgrade", definition.homebrewFormula], + updateLockKey: "homebrew", + }); +} + +function makeNativeProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities | null { + if (!definition.nativeUpdate) { + return null; + } + + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: definition.nativeUpdate.executable, + updateArgs: definition.nativeUpdate.args, + updateLockKey: definition.nativeUpdate.lockKey, + }); +} + +export function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +export function normalizeCommandPath(commandPath: string): string { + return commandPath.replaceAll("\\", "/").toLowerCase(); +} + +function isBunGlobalCommandPath(commandPath: string): boolean { + return normalizeCommandPath(commandPath).includes("/.bun/bin/"); +} + +function isVitePlusGlobalCommandPath(commandPath: string): boolean { + return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/"); +} + +function isPnpmGlobalCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/.local/share/pnpm/") || + normalized.includes("/library/pnpm/") || + normalized.includes("/local/share/pnpm/") || + normalized.includes("/appdata/local/pnpm/") || + normalized.includes("/pnpm/global/") + ); +} + +function isNpmGlobalCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/node_modules/.bin/") || + normalized.includes("/lib/node_modules/") || + normalized.includes("/npm/node_modules/") + ); +} + +function isHomebrewCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/opt/homebrew/cellar/") || + normalized.includes("/usr/local/cellar/") || + normalized.includes("/homebrew/cellar/") || + normalized.includes("/opt/homebrew/caskroom/") || + normalized.includes("/usr/local/caskroom/") || + normalized.includes("/homebrew/caskroom/") || + normalized.startsWith("/opt/homebrew/bin/") || + normalized.startsWith("/usr/local/bin/") + ); +} + +export function resolvePackageManagedProviderMaintenance( + definition: PackageManagedProviderMaintenanceDefinition, + options?: ProviderMaintenanceCapabilityResolutionOptions, +): ProviderMaintenanceCapabilities { + const binaryPath = nonEmptyString(options?.binaryPath); + if (!binaryPath) { + return makeNpmGlobalProviderMaintenanceCapabilities(definition); + } + + const resolvedCommandPath = + resolveCommandPath(binaryPath, { + ...(options?.platform ? { platform: options.platform } : {}), + ...(options?.env ? { env: options.env } : {}), + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + + if (resolvedCommandPath) { + const commandPaths = [ + resolvedCommandPath, + ...(options?.realCommandPath ? [options.realCommandPath] : []), + ]; + + const nativeUpdate = definition.nativeUpdate; + if ( + nativeUpdate && + commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) + ) { + return ( + makeNativeProviderMaintenanceCapabilities(definition) ?? + makeNpmGlobalProviderMaintenanceCapabilities(definition) + ); + } + if (commandPaths.some(isVitePlusGlobalCommandPath)) { + return makeVitePlusGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isBunGlobalCommandPath)) { + return makeBunGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isPnpmGlobalCommandPath)) { + return makePnpmGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isNpmGlobalCommandPath)) { + return makeNpmGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isHomebrewCommandPath)) { + return makeHomebrewProviderMaintenanceCapabilities(definition); + } + } + + if (!hasPathSeparator(binaryPath)) { + return makeNpmGlobalProviderMaintenanceCapabilities(definition); + } + + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); +} + +export function makePackageManagedProviderMaintenanceResolver( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilitiesResolver { + return { + resolve: (options) => resolvePackageManagedProviderMaintenance(definition, options), + }; +} + +export function makeStaticProviderMaintenanceResolver( + capabilities: ProviderMaintenanceCapabilities, +): ProviderMaintenanceCapabilitiesResolver { + return { + resolve: () => capabilities, + }; +} + +function makeManualProviderMaintenanceCapabilities( + provider: ProviderDriverKind, +): ProviderMaintenanceCapabilities { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider, + packageName: null, + }); +} + +export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( + "resolveProviderMaintenanceCapabilitiesEffect", +)(function* ( + resolver: ProviderMaintenanceCapabilitiesResolver, + options?: Omit, +) { + const binaryPath = nonEmptyString(options?.binaryPath); + if (!binaryPath) { + return resolver.resolve(options); + } + + const resolvedCommandPath = + resolveCommandPath(binaryPath, { + ...(options?.platform ? { platform: options.platform } : {}), + ...(options?.env ? { env: options.env } : {}), + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + if (!resolvedCommandPath) { + return resolver.resolve(options); + } + + const fileSystem = yield* FileSystem.FileSystem; + const realCommandPath = yield* fileSystem + .realPath(resolvedCommandPath) + .pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath))); + return resolver.resolve({ + ...options, + realCommandPath, + }); +}); + +function deriveVersionAdvisory(input: { + readonly currentVersion: string | null; + readonly latestVersion: string | null; +}): Pick { + if (!input.currentVersion) { + return { status: "unknown", message: null }; + } + if (!input.latestVersion) { + return { status: "unknown", message: null }; + } + if (compareSemverVersions(input.currentVersion, input.latestVersion) < 0) { + return { + status: "behind_latest", + message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE, + }; + } + return { status: "current", message: null }; +} + +export function createProviderVersionAdvisory(input: { + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly latestVersion?: string | null; + readonly checkedAt?: string | null; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; +}): ServerProviderVersionAdvisory { + const capabilities = + input.maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(input.driver); + const latestVersion = input.latestVersion ?? null; + const advisory = deriveVersionAdvisory({ + currentVersion: input.currentVersion, + latestVersion, + }); + + return { + status: advisory.status, + currentVersion: input.currentVersion, + latestVersion, + updateCommand: capabilities.update?.command ?? null, + canUpdate: capabilities.update !== null, + checkedAt: input.checkedAt ?? null, + message: advisory.message, + }; +} + +const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + ).pipe(HttpClientRequest.setHeader("accept", "application/json")); + const response = yield* client.execute(request).pipe( + Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), + Effect.catch(() => Effect.succeed(Option.none())), + ); + if (Option.isNone(response)) { + return null; + } + const httpResponse = response.value; + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return null; + } + const payload = yield* httpResponse.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), + Effect.catch(() => Effect.succeed(null)), + ); + return payload ? nonEmptyString(payload.version) : null; +}); + +export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( + maintenanceCapabilities: ProviderMaintenanceCapabilities, +) { + const packageName = maintenanceCapabilities.packageName; + if (!packageName) { + return null; + } + + const cached = latestVersionCache.get(packageName); + const now = DateTime.toEpochMillis(yield* DateTime.now); + if (cached && cached.expiresAt > now) { + return cached.version; + } + + const version = yield* fetchNpmLatestVersion(packageName); + latestVersionCache.set(packageName, { + expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, + version, + }); + return version; +}); + +export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( + "enrichProviderSnapshotWithVersionAdvisory", +)(function* (snapshot: ServerProvider, maintenanceCapabilities?: ProviderMaintenanceCapabilities) { + const capabilities = + maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver); + if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + checkedAt: snapshot.checkedAt, + maintenanceCapabilities: capabilities, + }), + }; + } + + const latestVersion = yield* resolveLatestProviderVersion(capabilities); + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + latestVersion, + checkedAt: DateTime.formatIso(yield* DateTime.now), + maintenanceCapabilities: capabilities, + }), + }; +}); diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts new file mode 100644 index 00000000000..7c456c3c484 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -0,0 +1,80 @@ +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +export interface ProviderMaintenanceCommandCoordinatorShape { + readonly withCommandLock: (input: { + readonly targetKey: string; + readonly lockKey: string; + readonly onQueued?: Effect.Effect; + readonly run: Effect.Effect; + }) => Effect.Effect; +} + +export const makeProviderMaintenanceCommandCoordinator = Effect.fn( + "makeProviderMaintenanceCommandCoordinator", +)(function* (input: { readonly makeAlreadyRunningError: (targetKey: string) => E }) { + const runningTargetsRef = yield* Ref.make>(new Set()); + const locksRef = yield* Ref.make>(new Map()); + + const acquireTarget = Effect.fn("acquireTarget")(function* (targetKey: string) { + return yield* Ref.modify(runningTargetsRef, (runningTargets) => { + if (runningTargets.has(targetKey)) { + return [false, runningTargets] as const; + } + const next = new Set(runningTargets); + next.add(targetKey); + return [true, next] as const; + }); + }); + + const releaseTarget = (targetKey: string) => + Ref.update(runningTargetsRef, (runningTargets) => { + const next = new Set(runningTargets); + next.delete(targetKey); + return next; + }); + + const getLock = Effect.fn("getProviderMaintenanceCommandLock")(function* (lockKey: string) { + const existing = (yield* Ref.get(locksRef)).get(lockKey); + if (existing) { + return existing; + } + + const lock = yield* Semaphore.make(1); + return yield* Ref.modify(locksRef, (locks) => { + const current = locks.get(lockKey); + if (current) { + return [current, locks] as const; + } + const next = new Map(locks); + next.set(lockKey, lock); + return [lock, next] as const; + }); + }); + + const withCommandLock: ProviderMaintenanceCommandCoordinatorShape["withCommandLock"] = ({ + targetKey, + lockKey, + onQueued, + run, + }) => + Effect.gen(function* () { + const acquired = yield* acquireTarget(targetKey); + if (!acquired) { + return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); + } + + return yield* Effect.gen(function* () { + const lock = yield* getLock(lockKey); + if (onQueued) { + yield* onQueued; + } + return yield* lock.withPermits(1)(run); + }).pipe(Effect.ensuring(releaseTarget(targetKey))); + }); + + return { + withCommandLock, + } satisfies ProviderMaintenanceCommandCoordinatorShape; +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts new file mode 100644 index 00000000000..5f5f975a4e3 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -0,0 +1,649 @@ +import { afterEach, describe, it, assert } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdateState, +} from "@t3tools/contracts"; +import { ServerProviderUpdateError } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; +import { + clearLatestProviderVersionCacheForTests, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; +const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); + +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); +const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); +const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); +const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); +const encoder = new TextEncoder(); + +afterEach(() => { + clearLatestProviderVersionCacheForTests(); +}); + +function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { + if (provider === CURSOR_DRIVER) { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: null, + updateExecutable: "agent", + updateArgs: ["update"], + updateLockKey: "cursor-agent", + }); + } + return makeProviderMaintenanceCapabilities({ + provider, + packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", + updateExecutable: "npm", + updateArgs: + provider === OPENCODE_DRIVER + ? ["install", "-g", "opencode-ai@latest"] + : ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + }); +} + +const baseProvider: ServerProvider = { + instanceId: CODEX_INSTANCE_ID, + driver: CODEX_DRIVER, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +const baseCursorProvider: ServerProvider = { + ...baseProvider, + instanceId: CURSOR_INSTANCE_ID, + driver: CURSOR_DRIVER, +}; + +const baseOpenCodeProvider: ServerProvider = { + ...baseProvider, + instanceId: OPENCODE_INSTANCE_ID, + driver: OPENCODE_DRIVER, +}; + +const latestVersionHttpClient = (version: string) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ version }, { headers: { "content-type": "application/json" } }), + ), + ), + ), + ); + +function mockHandle(result: { + readonly stdout?: string; + readonly stderr?: string; + readonly code?: number; + readonly exitCode?: Effect.Effect; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: result.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 mockSpawnerLayer( + handler: ( + command: string, + args: ReadonlyArray, + ) => { + readonly stdout?: string; + readonly stderr?: string; + readonly code?: number; + readonly exitCode?: Effect.Effect; + }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); + }), + ); +} + +function makeRegistry( + initialProviders: ServerProvider | ReadonlyArray = baseProvider, +) { + return Effect.gen(function* () { + const providersRef = yield* Ref.make>( + Array.isArray(initialProviders) ? initialProviders : [initialProviders], + ); + const updateStatesRef = yield* Ref.make>([]); + + const setProviderMaintenanceActionState = Effect.fn( + "providerMaintenanceRunner.test.setProviderMaintenanceActionState", + )(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly action: "update"; + readonly state: ServerProviderUpdateState | null; + }) { + const updateState = input.state; + if (updateState) { + yield* Ref.update(updateStatesRef, (states) => [...states, updateState]); + } + return yield* Ref.updateAndGet(providersRef, (providers) => + providers.map((candidate) => { + if (candidate.instanceId !== input.instanceId) { + return candidate; + } + if (!updateState) { + const { updateState: _updateState, ...providerWithoutUpdateState } = candidate; + return providerWithoutUpdateState; + } + return { + ...candidate, + updateState, + }; + }), + ); + }); + + const registry: ProviderRegistryShape = { + getProviders: Ref.get(providersRef), + refresh: () => Ref.get(providersRef), + refreshInstance: () => Ref.get(providersRef), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed(lifecycleFor(provider)), + setProviderMaintenanceActionState, + streamChanges: Stream.empty, + }; + + return { + registry, + updateStatesRef, + }; + }); +} + +const makeTestRunner = (registry: ProviderRegistryShape) => + Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( + Effect.provide( + ProviderMaintenanceRunner.layer.pipe( + Layer.provide(Layer.succeed(ProviderRegistry, registry)), + ), + ), + ); + +describe("providerMaintenanceRunner", () => { + it.effect("runs the allowlisted provider update command and records success", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseCursorProvider); + const updater = yield* makeTestRunner(registry); + + const result = yield* updater.updateProvider(CURSOR_DRIVER); + assert.deepStrictEqual(calls, [ + { + command: "agent", + args: ["update"], + }, + ]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.deepStrictEqual( + (yield* Ref.get(updateStatesRef)).map((state) => state.status), + ["queued", "running", "succeeded"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("uses the resolved provider capabilities when choosing the update executable", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.14", + latestVersion: "2.1.123", + updateCommand: "bun i -g @anthropic-ai/claude-code@latest", + canUpdate: true, + checkedAt: "2026-04-30T12:00:00.000Z", + message: "Update available.", + }, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "bun", + updateArgs: ["i", "-g", "@openai/codex@latest"], + updateLockKey: "bun-global", + }), + ), + }); + + yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [ + { + command: "bun", + args: ["i", "-g", "@openai/codex@latest"], + }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect( + "runs update commands through Effect ChildProcess when no test runner is injected", + () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner(registry); + + const result = yield* runner.updateProvider(CODEX_DRIVER); + + assert.deepStrictEqual(calls, [ + { + command: "npm", + args: ["install", "-g", "@openai/codex@latest"], + }, + ]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }, + ); + + it.effect("updates a single provider instance without touching sibling instances", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const personalInstanceId = ProviderInstanceId.make("codex_personal"); + const workInstanceId = ProviderInstanceId.make("codex_work"); + const refreshedInstanceIds: Array = []; + const { registry } = yield* makeRegistry([ + { + ...baseProvider, + instanceId: personalInstanceId, + version: "0.124.0-alpha.3", + }, + { + ...baseProvider, + instanceId: workInstanceId, + version: "0.124.0-alpha.3", + }, + ]); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider, + packageName: "@openai/codex-instance-test", + updateExecutable: "vp", + updateArgs: ["i", "-g", "@openai/codex"], + updateLockKey: "vite-plus-global", + }), + ).pipe( + Effect.tap(() => Effect.sync(() => assert.strictEqual(instanceId, personalInstanceId))), + ), + refreshInstance: (instanceId) => + registry.refreshInstance(instanceId).pipe( + Effect.tap(() => + Effect.sync(() => { + refreshedInstanceIds.push(instanceId); + }), + ), + ), + }); + + const result = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + instanceId: personalInstanceId, + }); + + assert.deepStrictEqual(calls, [ + { + command: "vp", + args: ["i", "-g", "@openai/codex"], + }, + ]); + assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]); + assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.strictEqual(result.providers[1]?.instanceId, workInstanceId); + assert.strictEqual(result.providers[1]?.updateState, undefined); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.124.0-alpha.3"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("records command failure output in provider update state", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + const updater = yield* makeTestRunner(registry); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + const updateState = result.providers[0]?.updateState; + + assert.strictEqual(updateState?.status, "failed"); + assert.strictEqual(updateState?.message, "Update command exited with code 1."); + assert.include(updateState?.output ?? "", "permission denied"); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })), + ), + ), + ), + ); + + it.effect( + "marks successful commands as unchanged when the refreshed provider is still outdated", + () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + installed: true, + version: "0.1.0", + }); + const updater = yield* makeTestRunner(registry); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.include(result.providers[0]?.updateState?.message ?? "", "still detects"); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("9.9.9"), + mockSpawnerLayer(() => ({ stdout: "updated" })), + ), + ), + ), + ); + + it.effect("prevents concurrent updates for the same provider", () => { + const startedLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseLatch: { resolve: () => void } = { resolve: () => {} }; + const started = new Promise((resolve) => { + startedLatch.resolve = resolve; + }); + const release = new Promise((resolve) => { + releaseLatch.resolve = resolve; + }); + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + const updater = yield* makeTestRunner(registry); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => started); + + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert.strictEqual(Exit.isFailure(second), true); + if (Exit.isFailure(second)) { + const error = Cause.squash(second.cause); + assert.strictEqual(isServerProviderUpdateError(error), true); + if (isServerProviderUpdateError(error)) { + assert.include(error.reason, "already running"); + } + } + + releaseLatch.resolve(); + yield* Fiber.join(first); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => { + startedLatch.resolve(); + return { + stdout: "updated", + exitCode: Effect.promise(() => release).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; + }), + ), + ), + ); + }); + + it.effect("serializes different providers that share the same update lock key", () => { + const firstStartedLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} }; + const firstStarted = new Promise((resolve) => { + firstStartedLatch.resolve = resolve; + }); + const releaseFirst = new Promise((resolve) => { + releaseFirstLatch.resolve = resolve; + }); + const calls: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider, + packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", + updateExecutable: "npm", + updateArgs: + provider === OPENCODE_DRIVER + ? ["install", "-g", "opencode-ai@latest"] + : ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + }), + ), + }); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => firstStarted); + + const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped); + let providersWhileQueued: ReadonlyArray = []; + for (let attempt = 0; attempt < 20; attempt += 1) { + providersWhileQueued = yield* registry.getProviders; + const queuedStatus = providersWhileQueued.find( + (provider) => provider.instanceId === OPENCODE_INSTANCE_ID, + )?.updateState?.status; + if (queuedStatus === "queued") { + break; + } + yield* Effect.yieldNow; + } + assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); + assert.strictEqual( + providersWhileQueued.find((provider) => provider.instanceId === OPENCODE_INSTANCE_ID) + ?.updateState?.status, + "queued", + ); + + releaseFirstLatch.resolve(); + yield* Fiber.join(first); + yield* Fiber.join(second); + assert.deepStrictEqual(calls, [ + "install -g @openai/codex@latest", + "install -g opencode-ai@latest", + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + if (calls.length === 1) { + firstStartedLatch.resolve(); + return { + stdout: "updated", + exitCode: Effect.promise(() => releaseFirst).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; + } + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("accepts arbitrary driver-provided update lock keys", () => { + const calls: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "unknown-lock-key", + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect( + "releases the running-provider marker when interrupted after queuing but before the lock run starts", + () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + let blockQueuedState = true; + const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} }; + const queuedStateWritten = new Promise((resolve) => { + queuedStateWrittenLatch.resolve = resolve; + }); + const releaseQueuedState = new Promise((resolve) => { + releaseQueuedStateLatch.resolve = resolve; + }); + + const updater = yield* makeTestRunner({ + ...registry, + setProviderMaintenanceActionState: Effect.fn( + "providerMaintenanceRunner.test.blockQueuedState", + )(function* (input) { + const providers = yield* registry.setProviderMaintenanceActionState(input); + if (input.state?.status === "queued" && blockQueuedState) { + queuedStateWrittenLatch.resolve(); + yield* Effect.promise(() => releaseQueuedState); + } + return providers; + }), + }); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => queuedStateWritten); + blockQueuedState = false; + + yield* Fiber.interrupt(first); + releaseQueuedStateLatch.resolve(); + + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert.strictEqual(Exit.isSuccess(second), true); + if (Exit.isSuccess(second)) { + assert.strictEqual(second.value.providers[0]?.updateState?.status, "succeeded"); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => ({ stdout: "updated" })), + ), + ), + ), + ); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts new file mode 100644 index 00000000000..5f76afb34d3 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -0,0 +1,416 @@ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + ServerProviderUpdateError, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdatedPayload, + type ServerProviderUpdateState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; +import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; +import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); + +const UPDATE_TIMEOUT_MS = 5 * 60_000; +const UPDATE_OUTPUT_MAX_BYTES = 10_000; + +export interface ProviderMaintenanceCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface ProviderMaintenanceRunnerShape { + readonly updateProvider: ( + target: + | ProviderDriverKind + | { + readonly provider: ProviderDriverKind; + readonly instanceId?: ProviderInstanceId | undefined; + }, + ) => Effect.Effect; +} + +export class ProviderMaintenanceRunner extends Context.Service< + ProviderMaintenanceRunner, + ProviderMaintenanceRunnerShape +>()("t3/provider/ProviderMaintenanceRunner") {} + +class ProviderMaintenanceCommandError extends Data.TaggedError("ProviderMaintenanceCommandError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +interface VerifiedProviderRefresh { + readonly providers: ReadonlyArray; + readonly verifiedProviders: ReadonlyArray; +} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")( + function* (input: { + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly command: string; + readonly args: ReadonlyArray; + }) { + const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( + function* () { + const child = yield* input.spawner + .spawn(ChildProcess.make(input.command, [...input.args])) + .pipe( + Effect.mapError( + (cause) => + new ProviderMaintenanceCommandError({ + message: `Failed to run update command ${input.command}: ${cause.message}`, + cause, + }), + ), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ + stream: child.stdout, + maxBytes: UPDATE_OUTPUT_MAX_BYTES, + }), + collectUint8StreamText({ + stream: child.stderr, + maxBytes: UPDATE_OUTPUT_MAX_BYTES, + }), + child.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderMaintenanceCommandError({ + message: cause instanceof Error ? cause.message : "Update command failed to run.", + cause, + }), + ), + ); + + return { + stdout: stdout.text, + stderr: stderr.text, + exitCode: Number(exitCode), + timedOut: false, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies ProviderMaintenanceCommandResult; + }, + ); + + return yield* collectCommandResult().pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)), + Effect.map((result) => + Option.match(result, { + onSome: (value) => value, + onNone: () => + ({ + stdout: "", + stderr: "", + exitCode: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + }) satisfies ProviderMaintenanceCommandResult, + }), + ), + ); + }, +); + +function trimNullable(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function truncateText(value: string, maxLength: number): string { + return value.length <= maxLength ? value : value.slice(0, maxLength); +} + +function commandOutput(result: ProviderMaintenanceCommandResult): string | null { + const output = trimNullable([result.stderr, result.stdout].filter(Boolean).join("\n\n")); + if (!output) { + return null; + } + return truncateText(output, UPDATE_OUTPUT_MAX_BYTES); +} + +function failureMessage(result: ProviderMaintenanceCommandResult): string { + if (result.timedOut) { + return "Update timed out."; + } + if (result.exitCode !== null && result.exitCode !== 0) { + return `Update command exited with code ${result.exitCode}.`; + } + return "Update command failed."; +} + +function isOutdatedProvider(provider: ServerProvider | undefined): boolean { + return provider?.versionAdvisory?.status === "behind_latest"; +} + +function makeUpdateState(input: { + readonly status: ServerProviderUpdateState["status"]; + readonly startedAt: string | null; + readonly finishedAt: string | null; + readonly message: string | null; + readonly output?: string | null; +}): ServerProviderUpdateState { + return { + status: input.status, + startedAt: input.startedAt, + finishedAt: input.finishedAt, + message: input.message, + output: input.output ?? null, + }; +} + +export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { + const providerRegistry = yield* ProviderRegistry; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const runMaintenanceCommand = (command: string, args: ReadonlyArray) => + runProviderMaintenanceCommandWithSpawner({ + spawner, + command, + args, + }); + const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ + makeAlreadyRunningError: () => + new ServerProviderUpdateError({ + provider: ProviderDriverKind.make("unknown"), + reason: "An update is already running for this provider.", + }), + }); + + const verifyRefreshedProvider = ( + provider: ProviderDriverKind, + maintenanceCapabilities: ProviderMaintenanceCapabilities, + instanceId: ProviderInstanceId, + ): Effect.Effect => + providerRegistry.getProviders.pipe( + Effect.map((providers) => + providers + .filter( + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, + ) + .map((candidate) => candidate.instanceId), + ), + Effect.flatMap((instanceIds) => + instanceIds.length === 0 + ? providerRegistry.refreshInstance(instanceId) + : Effect.forEach( + instanceIds, + (instanceId) => providerRegistry.refreshInstance(instanceId), + { + concurrency: "unbounded", + discard: true, + }, + ).pipe(Effect.andThen(providerRegistry.getProviders)), + ), + Effect.flatMap((providers) => { + const refreshedProviders = providers.filter( + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, + ); + if (refreshedProviders.length === 0) { + return Effect.succeed({ + providers, + verifiedProviders: [], + }); + } + return Effect.forEach( + refreshedProviders, + (refreshedProvider) => + enrichProviderSnapshotWithVersionAdvisory( + refreshedProvider, + maintenanceCapabilities, + ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + { + concurrency: "unbounded", + }, + ).pipe( + Effect.map( + (verifiedProviders): VerifiedProviderRefresh => ({ + providers, + verifiedProviders, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("Provider post-update version verification failed", { + provider, + cause: Cause.pretty(cause), + }).pipe( + Effect.as({ + providers, + verifiedProviders: refreshedProviders, + }), + ), + ), + ); + }), + ); + + const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn( + "ProviderMaintenanceRunner.updateProvider", + )(function* (target) { + const provider = typeof target === "string" ? target : target.provider; + const instanceId = + typeof target === "string" + ? defaultInstanceIdForDriver(provider) + : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetKey = `instance:${instanceId}`; + const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( + instanceId, + provider, + ); + const update = capabilities.update; + if (!update) { + return yield* new ServerProviderUpdateError({ + provider, + reason: "This provider does not support one-click updates.", + }); + } + + const setUpdateState = (state: ServerProviderUpdateState | null) => + providerRegistry.setProviderMaintenanceActionState({ + instanceId, + action: "update", + state, + }); + const setQueuedState = setUpdateState( + makeUpdateState({ + status: "queued", + startedAt: null, + finishedAt: null, + message: "Waiting for another provider update to finish.", + }), + ).pipe(Effect.asVoid); + + const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")( + function* () { + const finish = (state: ServerProviderUpdateState) => + setUpdateState(state).pipe(Effect.map((providers) => ({ providers }))); + const startedAtRef = yield* Ref.make(null); + + const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")( + function* () { + const startedAt = yield* nowIso; + yield* Ref.set(startedAtRef, startedAt); + yield* setUpdateState( + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message: "Updating provider.", + }), + ); + + const result = yield* runMaintenanceCommand(update.executable, update.args); + const finishedAt = yield* nowIso; + if (result.timedOut || result.exitCode !== 0) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt, + message: failureMessage(result), + output: commandOutput(result), + }), + ); + } + + const { verifiedProviders } = yield* verifyRefreshedProvider( + provider, + capabilities, + instanceId, + ); + const couldNotVerify = verifiedProviders.length === 0; + const stillOutdated = + couldNotVerify || + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); + return yield* finish( + makeUpdateState({ + status: stillOutdated ? "unchanged" : "succeeded", + startedAt, + finishedAt, + message: couldNotVerify + ? "Update command completed, but T3 Code could not verify the provider version." + : stillOutdated + ? "Update command completed, but T3 Code still detects an outdated provider version." + : "Provider updated.", + output: commandOutput(result), + }), + ); + }, + ); + + const recordFailedUpdate = Effect.fn("ProviderMaintenanceRunner.recordFailedUpdate")( + function* (cause: Cause.Cause) { + const failure = Cause.squash(cause); + const startedAt = yield* Ref.get(startedAtRef); + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: yield* nowIso, + message: failure instanceof Error ? failure.message : "Update command failed.", + output: null, + }), + ); + }, + ); + + return yield* runCommandAndVerify().pipe(Effect.catchCause(recordFailedUpdate)); + }, + ); + + return yield* commandCoordinator + .withCommandLock({ + targetKey, + lockKey: update.lockKey, + onQueued: setQueuedState, + run: runProviderUpdate(), + }) + .pipe( + Effect.mapError((error) => + isServerProviderUpdateError(error) + ? new ServerProviderUpdateError({ + provider, + reason: error.reason, + }) + : error, + ), + ); + }); + + return ProviderMaintenanceRunner.of({ + updateProvider, + }); +}); + +export const layer = Layer.effect(ProviderMaintenanceRunner, make()); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts new file mode 100644 index 00000000000..449dca8fc5a --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { providerModelsFromSettings } from "./providerSnapshot.ts"; + +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [{ id: "medium", label: "Medium", isDefault: true }], + currentValue: "medium", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build", isDefault: true }], + currentValue: "build", + }, + ], +}); + +describe("providerModelsFromSettings", () => { + it("applies the provided capabilities to custom models", () => { + const models = providerModelsFromSettings( + [], + ProviderDriverKind.make("opencode"), + ["openai/gpt-5"], + OPENCODE_CUSTOM_MODEL_CAPABILITIES, + ); + + expect(models).toEqual([ + { + slug: "openai/gpt-5", + name: "openai/gpt-5", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 40246563aef..c40903e1b45 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + ProviderDriverKind, ModelCapabilities, ServerProvider, ServerProviderAuth, @@ -7,12 +8,18 @@ import type { ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; -import { Effect, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Data from "effect/Data"; +import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { isWindowsCommandNotFound } from "../processRunner"; +import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; export const DEFAULT_TIMEOUT_MS = 4_000; +// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) +export const AUTH_PROBE_TIMEOUT_MS = 10_000; export interface CommandResult { readonly stdout: string; @@ -20,6 +27,12 @@ export interface CommandResult { readonly code: number; } +export class ProviderCommandExecutionError extends Data.TaggedError( + "ProviderCommandExecutionError", +)<{ + readonly message: string; +}> {} + export interface ProviderProbeResult { readonly installed: boolean; readonly version: string | null; @@ -28,13 +41,21 @@ export interface ProviderProbeResult { readonly message?: string; } +export interface ServerProviderPresentation { + readonly displayName: string; + readonly badgeLabel?: string; + readonly showInteractionModeToggle?: boolean; +} + +export type ServerProviderDraft = Omit; + export function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } -export function isCommandMissingCause(error: Error): boolean { +export function isCommandMissingCause(error: { readonly message: string }): boolean { const lower = error.message.toLowerCase(); return lower.includes("enoent") || lower.includes("notfound"); } @@ -54,7 +75,7 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman const result: CommandResult = { stdout, stderr, code: exitCode }; if (isWindowsCommandNotFound(exitCode, stderr)) { - return yield* Effect.fail(new Error(`spawn ${binaryPath} ENOENT`)); + return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); } return result; }).pipe(Effect.scoped); @@ -102,7 +123,7 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, - provider: ServerProvider["provider"], + provider: ProviderDriverKind, customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { @@ -127,17 +148,72 @@ export function providerModelsFromSettings( return [...resolvedBuiltInModels, ...customEntries]; } +export function buildSelectOptionDescriptor(input: { + readonly id: string; + readonly label: string; + readonly options: + | ReadonlyArray<{ value: string; label: string; isDefault?: boolean | undefined }> + | undefined; + readonly description?: string; + readonly promptInjectedValues?: ReadonlyArray; +}) { + const options = (input.options ?? []).map((option) => + option.isDefault + ? { id: option.value, label: option.label, isDefault: true } + : { id: option.value, label: option.label }, + ); + const currentValue = options.find((option) => option.isDefault)?.id; + return { + id: input.id, + label: input.label, + type: "select" as const, + options, + ...(currentValue ? { currentValue } : {}), + ...(input.description ? { description: input.description } : {}), + ...(input.promptInjectedValues && input.promptInjectedValues.length > 0 + ? { promptInjectedValues: [...input.promptInjectedValues] } + : {}), + }; +} + +export function buildBooleanOptionDescriptor(input: { + readonly id: string; + readonly label: string; + readonly currentValue?: boolean; + readonly description?: string; +}) { + return { + id: input.id, + label: input.label, + type: "boolean" as const, + ...(input.description ? { description: input.description } : {}), + ...(typeof input.currentValue === "boolean" ? { currentValue: input.currentValue } : {}), + }; +} + export function buildServerProvider(input: { - provider: ServerProvider["provider"]; + driver?: ProviderDriverKind; + presentation: ServerProviderPresentation; enabled: boolean; checkedAt: string; models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; probe: ProviderProbeResult; -}): ServerProvider { +}): ServerProviderDraft { + const versionAdvisory = input.driver + ? createProviderVersionAdvisory({ + driver: input.driver, + currentVersion: input.probe.version, + checkedAt: input.checkedAt, + }) + : undefined; return { - provider: input.provider, + displayName: input.presentation.displayName, + ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), + ...(typeof input.presentation.showInteractionModeToggle === "boolean" + ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } + : {}), enabled: input.enabled, installed: input.probe.installed, version: input.probe.version, @@ -148,16 +224,11 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(versionAdvisory ? { versionAdvisory } : {}), }; } export const collectStreamAsString = ( stream: Stream.Stream, ): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ); + collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts new file mode 100644 index 00000000000..64cb9ccd417 --- /dev/null +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -0,0 +1,237 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { + hydrateCachedProvider, + isCachedProviderCorrelated, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "./providerStatusCache.ts"; + +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); + +const makeProvider = ( + provider: ProviderDriverKind, + overrides?: Partial, +): ServerProvider => ({ + instanceId: defaultInstanceIdForDriver(provider), + driver: provider, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, +}); + +it.layer(NodeServices.layer)("providerStatusCache", (it) => { + it.effect("writes and reads provider status snapshots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); + const codexProvider = makeProvider(CODEX_DRIVER); + const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, { + status: "warning", + auth: { status: "unknown" }, + }); + const openCodeProvider = makeProvider(OPENCODE_DRIVER, { + status: "warning", + auth: { status: "unknown", type: "opencode" }, + }); + const codexPath = yield* resolveProviderStatusCachePath({ + cacheDir: tempDir, + instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), + }); + const claudePath = yield* resolveProviderStatusCachePath({ + cacheDir: tempDir, + instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("claudeAgent")), + }); + const openCodePath = yield* resolveProviderStatusCachePath({ + cacheDir: tempDir, + instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("opencode")), + }); + + yield* writeProviderStatusCache({ + filePath: codexPath, + provider: codexProvider, + }); + yield* writeProviderStatusCache({ + filePath: claudePath, + provider: claudeProvider, + }); + yield* writeProviderStatusCache({ + filePath: openCodePath, + provider: openCodeProvider, + }); + + assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); + assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); + assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); + }), + ); + + it("hydrates cached provider status while preserving current settings-derived models", () => { + const cachedCodex = makeProvider(CODEX_DRIVER, { + checkedAt: "2026-04-10T12:00:00.000Z", + models: [ + { + slug: "gpt-5-mini", + name: "GPT-5 Mini", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + message: "Cached message", + skills: [ + { + name: "github:gh-fix-ci", + path: "/tmp/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + }, + ], + }); + const fallbackCodex = makeProvider(CODEX_DRIVER, { + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + message: "Pending refresh", + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: fallbackCodex, + }), + { + ...fallbackCodex, + models: [ + ...fallbackCodex.models, + { + slug: "gpt-5-mini", + name: "GPT-5 Mini", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + installed: cachedCodex.installed, + version: cachedCodex.version, + status: cachedCodex.status, + auth: cachedCodex.auth, + checkedAt: cachedCodex.checkedAt, + slashCommands: cachedCodex.slashCommands, + skills: cachedCodex.skills, + message: cachedCodex.message, + }, + ); + }); + + it("ignores stale cached enabled state when the provider is now disabled", () => { + const cachedCodex = makeProvider(CODEX_DRIVER, { + checkedAt: "2026-04-10T12:00:00.000Z", + message: "Cached ready status", + }); + const disabledFallback = makeProvider(CODEX_DRIVER, { + enabled: false, + installed: false, + version: null, + status: "disabled", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: disabledFallback, + }), + disabledFallback, + ); + }); + + it("rejects cached snapshots that are not correlated to the fallback instance", () => { + const fallbackCodex = makeProvider(CODEX_DRIVER, { + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + const legacyCachedCodex = { + provider: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T12:00:00.000Z", + models: [ + { + slug: "cached-legacy-model", + name: "Cached Legacy Model", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + slashCommands: [], + skills: [], + } as unknown as ServerProvider; + const mismatchedCachedCodex = makeProvider(CODEX_DRIVER, { + instanceId: ProviderInstanceId.make("codex_personal"), + }); + + assert.strictEqual( + isCachedProviderCorrelated({ + cachedProvider: legacyCachedCodex, + fallbackProvider: fallbackCodex, + }), + false, + ); + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: legacyCachedCodex, + fallbackProvider: fallbackCodex, + }), + fallbackCodex, + ); + assert.strictEqual( + isCachedProviderCorrelated({ + cachedProvider: mismatchedCachedCodex, + fallbackProvider: fallbackCodex, + }), + false, + ); + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: mismatchedCachedCodex, + fallbackProvider: fallbackCodex, + }), + fallbackCodex, + ); + }); +}); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts new file mode 100644 index 00000000000..0b9b365f360 --- /dev/null +++ b/apps/server/src/provider/providerStatusCache.ts @@ -0,0 +1,153 @@ +import { + type ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, + ServerProvider as ServerProviderSchema, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; + +const decodeProviderStatusCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(ServerProviderSchema), +); + +const mergeProviderModels = ( + fallbackModels: ReadonlyArray, + cachedModels: ReadonlyArray, +): ReadonlyArray => { + const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); + return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))]; +}; + +export const orderProviderSnapshots = ( + providers: ReadonlyArray, +): ReadonlyArray => + [...providers].toSorted( + (left, right) => + (left.displayName ?? "").localeCompare(right.displayName ?? "") || + left.driver.localeCompare(right.driver) || + left.instanceId.localeCompare(right.instanceId), + ); + +export const isCachedProviderCorrelated = (input: { + readonly cachedProvider: ServerProvider; + readonly fallbackProvider: ServerProvider; +}): boolean => + input.cachedProvider.instanceId === input.fallbackProvider.instanceId && + input.cachedProvider.driver === input.fallbackProvider.driver; + +export const hydrateCachedProvider = (input: { + readonly cachedProvider: ServerProvider; + readonly fallbackProvider: ServerProvider; +}): ServerProvider => { + if (!isCachedProviderCorrelated(input)) { + return input.fallbackProvider; + } + + if ( + !input.fallbackProvider.enabled || + input.cachedProvider.enabled !== input.fallbackProvider.enabled + ) { + return input.fallbackProvider; + } + + const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; + const hydratedProvider: ServerProvider = { + ...fallbackWithoutMessage, + models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models), + installed: input.cachedProvider.installed, + version: input.cachedProvider.version, + status: input.cachedProvider.status, + auth: input.cachedProvider.auth, + checkedAt: input.cachedProvider.checkedAt, + slashCommands: input.cachedProvider.slashCommands, + skills: input.cachedProvider.skills, + }; + + return input.cachedProvider.message + ? { ...hydratedProvider, message: input.cachedProvider.message } + : hydratedProvider; +}; + +/** + * Resolve the on-disk cache path for a provider instance snapshot. + * + * File naming: `/.json`. For the default instance of + * a built-in kind this equals the legacy `.json` path (because + * `defaultInstanceIdForDriver(kind).toString() === kind`), so existing + * cached snapshots remain readable without any rename step. + * + * Non-default instances (e.g. `codex_personal`) land in their own files and + * never collide with other instances. + * + * Cache contents must still carry matching `instanceId` + `driver` identity + * before hydration. The filename alone is not trusted as a routing key. + */ +export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")( + function* (input: { + readonly cacheDir: string; + readonly instanceId: ProviderInstanceId; + }): Effect.fn.Return { + const path = yield* Path.Path; + return path.join(input.cacheDir, `${input.instanceId}.json`); + }, +); + +/** + * Legacy kind-keyed path resolver retained for callers that still think in + * terms of `ProviderDriverKind`. Prefer `resolveProviderStatusCachePath` with an + * `instanceId`; new code should route through the instance registry. + * + * @deprecated use `resolveProviderStatusCachePath` with an instance id. + */ +export const resolveLegacyProviderStatusCachePath = Effect.fn( + "resolveLegacyProviderStatusCachePath", +)(function* (input: { + readonly cacheDir: string; + readonly provider: ProviderDriverKind; +}): Effect.fn.Return { + const path = yield* Path.Path; + return path.join(input.cacheDir, `${input.provider}.json`); +}); + +export const readProviderStatusCache = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return undefined; + } + + const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return undefined; + } + + return yield* decodeProviderStatusCache(trimmed).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("failed to parse provider status cache, ignoring", { + path: filePath, + issues: Cause.pretty(cause), + }).pipe(Effect.as(undefined)), + onSuccess: Effect.succeed, + }), + ); + }); + +export const writeProviderStatusCache = (input: { + readonly filePath: string; + readonly provider: ServerProvider; +}) => { + const { updateState: _updateState, ...cacheableProvider } = input.provider; + return writeFileStringAtomically({ + filePath: input.filePath, + contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`, + }); +}; diff --git a/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts new file mode 100644 index 00000000000..c696a51c37e --- /dev/null +++ b/apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts @@ -0,0 +1,99 @@ +/** + * Test helpers for constructing a `ProviderAdapterRegistryShape` mock from a + * kind-keyed adapter map. + * + * Tests historically assembled a `registry` object with only `getByProvider` + * + `listProviders` populated. Slice D grew the shape with `getByInstance` + * and `listInstances`; this helper fills both in from a single kind-keyed + * input so individual fixtures can stay concise. + * + * Non-default instance ids (e.g. `codex_personal`) are not addressable via + * the shim returned here — the legacy test fixtures only ever had + * single-instance-per-driver data anyway. + * + * @module provider/testUtils/providerAdapterRegistryMock + */ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Record from "effect/Record"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; + +import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import type { ProviderAdapterRegistryShape } from "../Services/ProviderAdapterRegistry.ts"; + +export type KindAdapterMap = Partial< + Record> +>; + +/** + * Build a `ProviderAdapterRegistryShape` from a kind-keyed adapter map. + * Every adapter present in the map is addressable via both the legacy + * `getByProvider(kind)` path and the new `getByInstance(id)` path (where + * `id = defaultInstanceIdForDriver(kind)`). + */ +export const makeAdapterRegistryMock = (adapters: KindAdapterMap): ProviderAdapterRegistryShape => { + const byInstanceId = new Map>(); + for (const [kind, adapter] of Object.entries(adapters)) { + if (!adapter) continue; + const driverKind = ProviderDriverKind.make(kind); + byInstanceId.set(defaultInstanceIdForDriver(driverKind), adapter); + } + + const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => { + const adapter = byInstanceId.get(instanceId); + return adapter + ? Effect.succeed(adapter) + : Effect.fail( + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make(instanceId), + }), + ); + }; + + return { + getByInstance, + getInstanceInfo: (instanceId) => { + const adapter = byInstanceId.get(instanceId); + if (!adapter) { + return Effect.fail( + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make(instanceId), + }), + ); + } + return Effect.succeed({ + instanceId, + driverKind: ProviderDriverKind.make(adapter.provider), + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: ProviderDriverKind.make(adapter.provider), + continuationKey: `${adapter.provider}:instance:${instanceId}`, + }, + }); + }, + listInstances: () => Effect.succeed(Array.from(byInstanceId.keys())), + listProviders: () => + Effect.succeed( + Record.keys( + Record.filterMap(adapters, (adapter, kind) => + adapter !== undefined ? Result.succeed(kind) : Result.failVoid, + ), + ), + ), + // Static test fixtures don't reload; an empty stream is enough to + // satisfy the shape. Tests exercising hot-reload build their own + // stream via the real `ProviderInstanceRegistry`. + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; +}; diff --git a/apps/server/src/provider/unavailableProviderSnapshot.ts b/apps/server/src/provider/unavailableProviderSnapshot.ts new file mode 100644 index 00000000000..de0c799a8cd --- /dev/null +++ b/apps/server/src/provider/unavailableProviderSnapshot.ts @@ -0,0 +1,79 @@ +/** + * Helpers for synthesizing "unavailable" `ServerProvider` snapshots. + * + * When `ServerSettings.providerInstances` (or persisted thread/session + * state) references a driver this build does not ship — typical after a + * downgrade from a fork or a feature-branch test session — the runtime + * needs to surface the entry to the UI without crashing. This module + * produces shadow snapshots that satisfy `ServerProvider`'s wire shape + * while signalling unavailability. + * + * @module unavailableProviderSnapshot + */ +import { + ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { buildServerProvider } from "./providerSnapshot.ts"; + +export interface UnavailableProviderSnapshotInput { + readonly driverKind: ProviderDriverKind | string; + readonly instanceId: ProviderInstanceId; + readonly displayName?: string | undefined; + readonly accentColor?: string | undefined; + readonly reason: string; + /** + * Optional override for `checkedAt`. Defaulted to the current Effect + * `DateTime` so callers + * (notably tests) don't have to pass it. + */ + readonly checkedAt?: string; +} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +/** + * Produce a `ServerProvider` snapshot representing a configured instance + * whose driver the running build does not implement. The result is safe + * to broadcast over the wire and is structured so the web UI can render + * a "missing driver" affordance without special-casing. + */ +export function buildUnavailableProviderSnapshot( + input: UnavailableProviderSnapshotInput, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = input.checkedAt ?? (yield* nowIso); + const displayName = input.displayName?.trim() || (input.driverKind as string); + + const base = buildServerProvider({ + presentation: { displayName }, + enabled: false, + checkedAt, + models: [], + skills: [], + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: input.reason, + }, + }); + + return { + ...base, + instanceId: input.instanceId, + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + driver: + typeof input.driverKind === "string" + ? ProviderDriverKind.make(input.driverKind) + : input.driverKind, + availability: "unavailable", + unavailableReason: input.reason, + }; + }); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fbbab1c84a2..3ae2885f024 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10,12 +10,15 @@ import { GitCommandError, KeybindingRule, MessageId, - OpenError, + ExternalLauncherError, + type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ResolvedKeybindingRule, ThreadId, WS_METHODS, @@ -24,16 +27,18 @@ import { } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; -import { - Deferred, - Duration, - Effect, - FileSystem, - Layer, - ManagedRuntime, - Path, - Stream, -} from "effect"; +import * as Clock from "effect/Clock"; +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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, HttpBody, @@ -46,6 +51,8 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vitest"; +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; @@ -54,11 +61,9 @@ import { CheckpointDiffQuery, type CheckpointDiffQueryShape, } from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; -import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; +import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; -import { Open, type OpenShape } from "./open.ts"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -68,12 +73,13 @@ import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { PersistenceSqlError } from "./persistence/Errors.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; +import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderRegistry, type ProviderRegistryShape, } from "./provider/Services/ProviderRegistry.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; @@ -85,6 +91,7 @@ import { import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { ProjectSetupScriptRunner, + ProjectSetupScriptRunnerError, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; import { @@ -98,14 +105,25 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as VcsDriver from "./vcs/VcsDriver.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; +import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; const defaultModelSelection = { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; const testEnvironmentDescriptor = { @@ -121,7 +139,7 @@ const testEnvironmentDescriptor = { }, }; const makeDefaultOrchestrationReadModel = () => { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; return { snapshotSequence: 0, updatedAt: now, @@ -162,15 +180,31 @@ const makeDefaultOrchestrationReadModel = () => { }; }; -const workspaceAndProjectServicesLayer = Layer.mergeAll( - WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), - ProjectFaviconResolverLive, -); +const makeDefaultOrchestrationThreadShell = ( + overrides: Partial = {}, +): OrchestrationThreadShell => { + const now = "2026-01-01T00:00:00.000Z"; + return { + id: defaultThreadId, + projectId: defaultProjectId, + title: "Default Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +}; const browserOtlpTracingLayer = Layer.mergeAll( FetchHttpClient.layer, @@ -178,10 +212,8 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -const authTestLayer = ServerAuthLive.pipe( - Layer.provide(SqlitePersistenceMemory), - Layer.provide(ServerSecretStoreLive), -); +const makeAuthTestLayer = () => + ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive)); const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { @@ -272,15 +304,13 @@ const makeBrowserOtlpPayload = (spanName: string) => yield* Effect.promise(() => runtime.dispose()); } - const request = yield* Effect.promise(() => - Promise.race([ - collector.firstRequest, - new Promise((_, reject) => { - setTimeout(() => reject(new Error("Timed out waiting for OTLP trace export")), 1_000); - }), - ]), + const request = yield* Effect.raceFirst( + Effect.promise(() => collector.firstRequest).pipe(Effect.orDie), + Effect.sleep(Duration.seconds(1)).pipe( + Effect.andThen(Effect.die(new Error("Timed out waiting for OTLP trace export"))), + ), ); - + // @effect-diagnostics-next-line preferSchemaOverJson:off return JSON.parse(request.body) as OtlpTracer.TraceData; }); @@ -290,9 +320,13 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; providerRegistry?: Partial; serverSettings?: Partial; - open?: Partial; - gitCore?: Partial; + externalLauncher?: Partial; + vcsDriver?: Partial; + vcsDriverRegistry?: Partial; + gitVcsDriver?: Partial; gitManager?: Partial; + sourceControlRepositoryService?: Partial; + vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -335,13 +369,138 @@ const buildAppUnderTest = (options?: { desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const defaultVcsDriver: VcsDriver.VcsDriverShape = { + capabilities: { + kind: "git", + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native", + }, + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + detectRepository: () => Effect.succeed(null), + isInsideWorkTree: () => Effect.succeed(false), + listWorkspaceFiles: () => + Effect.succeed({ + paths: [], + truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + listRemotes: () => + Effect.succeed({ + remotes: [], + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + initRepository: () => Effect.void, + ...options?.layers?.vcsDriver, + }; + const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: () => Effect.succeed(defaultVcsDriver), + detect: (input) => + defaultVcsDriver.detectRepository(input.cwd).pipe( + Effect.flatMap((repository) => + repository + ? Effect.succeed(repository) + : defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( + Effect.map((isInsideWorkTree) => + isInsideWorkTree + ? { + kind: "git" as const, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + } + : null, + ), + ), + ), + Effect.map((repository) => + repository + ? ({ + kind: repository.kind, + repository, + driver: defaultVcsDriver, + } satisfies VcsDriverRegistry.VcsDriverHandle) + : null, + ), + ), + resolve: (input) => + Effect.succeed({ + kind: + input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, + repository: { + kind: + input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: defaultVcsDriver, + }), + ...options?.layers?.vcsDriverRegistry, + }); + const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + ...options?.layers?.gitVcsDriver, + }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); - const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); + const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(vcsDriverRegistryLayer), + ); + const workspaceAndProjectServicesLayer = Layer.mergeAll( + WorkspacePathsLive, + workspaceEntriesLayer, + WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(workspaceEntriesLayer), + ), + ProjectFaviconResolverLive, + ); + const gitWorkflowLayer = GitWorkflowService.layer.pipe( + Layer.provideMerge(vcsDriverRegistryLayer), + Layer.provideMerge(gitVcsDriverLayer), + Layer.provideMerge(gitManagerLayer), + ); + const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( + Layer.provide(vcsDriverRegistryLayer), + ); + const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster + ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + ...options.layers.vcsStatusBroadcaster, + }) + : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -361,6 +520,12 @@ const buildAppUnderTest = (options?: { Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), streamChanges: Stream.empty, ...options?.layers?.providerRegistry, }), @@ -376,17 +541,82 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(Open)({ - ...options?.layers?.open, + Layer.mock(ExternalLauncher.ExternalLauncher)({ + ...options?.layers?.externalLauncher, + }), + ), + Layer.provide( + Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ + read: Effect.succeed({ + serverPid: process.pid, + readAt: TEST_EPOCH, + processCount: 0, + totalRssBytes: 0, + totalCpuPercent: 0, + processes: [], + error: Option.none(), + }), + signal: (input) => + Effect.succeed({ + pid: input.pid, + signal: input.signal, + signaled: true, + message: Option.none(), + }), + }), + ), + Layer.provide( + Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ + readHistory: (input) => + Effect.succeed({ + readAt: TEST_EPOCH, + windowMs: input.windowMs, + bucketMs: input.bucketMs, + sampleIntervalMs: 5_000, + retainedSampleCount: 0, + totalCpuSecondsApprox: 0, + buckets: [], + topProcesses: [], + error: Option.none(), + }), }), ), Layer.provide( - Layer.mock(GitCore)({ - ...options?.layers?.gitCore, + Layer.mock(TraceDiagnostics.TraceDiagnostics)({ + read: () => + Effect.succeed({ + traceFilePath: "", + scannedFilePaths: [], + readAt: TEST_EPOCH, + recordCount: 0, + parseErrorCount: 0, + firstSpanAt: Option.none(), + lastSpanAt: Option.none(), + failureCount: 0, + interruptionCount: 0, + slowSpanThresholdMs: 1_000, + slowSpanCount: 0, + logLevelCounts: {}, + topSpansByCount: [], + slowestSpans: [], + commonFailures: [], + latestFailures: [], + latestWarningAndErrorLogs: [], + partialFailure: Option.none(), + error: Option.none(), + }), }), ), Layer.provide(gitManagerLayer), - Layer.provideMerge(gitStatusBroadcasterLayer), + Layer.provide(gitVcsDriverLayer), + Layer.provide(gitWorkflowLayer), + Layer.provide(vcsProvisioningLayer), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + ...options?.layers?.sourceControlRepositoryService, + }), + ), + Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -400,7 +630,6 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(OrchestrationEngineService)({ - getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 0 }), streamDomainEvents: Stream.empty, @@ -409,7 +638,30 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ProjectionSnapshotQuery)({ + getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), ), @@ -470,7 +722,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), - Layer.provideMerge(authTestLayer), + Layer.provideMerge(makeAuthTestLayer()), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -567,7 +819,12 @@ const bootstrapBrowserSession = ( }; }); -const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => +const bootstrapBearerSession = ( + credential = defaultDesktopBootstrapToken, + options?: { + readonly headers?: Record; + }, +) => Effect.gen(function* () { const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer"); const response = yield* Effect.promise(() => @@ -575,6 +832,7 @@ const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => method: "POST", headers: { "content-type": "application/json", + ...options?.headers, }, body: JSON.stringify({ credential, @@ -594,17 +852,23 @@ const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => }; }); +class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{ + readonly message: string; +}> {} + const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { const { response, cookie } = yield* bootstrapBrowserSession(credential); if (!response.ok) { - return yield* Effect.fail( - new Error(`Expected bootstrap session response to succeed, got ${response.status}`), - ); + return yield* new AuthenticationGetterError({ + message: `Expected bootstrap session response to succeed, got ${response.status}`, + }); } if (!cookie) { - return yield* Effect.fail(new Error("Expected bootstrap session response to set a cookie.")); + return yield* new AuthenticationGetterError({ + message: "Expected bootstrap session response to set a cookie.", + }); } return cookie.split(";")[0] ?? cookie; @@ -614,15 +878,15 @@ const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrap Effect.gen(function* () { const { response, body } = yield* bootstrapBearerSession(credential); if (!response.ok) { - return yield* Effect.fail( - new Error(`Expected bearer bootstrap response to succeed, got ${response.status}`), - ); + return yield* new AuthenticationGetterError({ + message: `Expected bearer bootstrap response to succeed, got ${response.status}`, + }); } if (!body.sessionToken) { - return yield* Effect.fail( - new Error("Expected bearer bootstrap response to include a session token."), - ); + return yield* new AuthenticationGetterError({ + message: "Expected bearer bootstrap response to include a session token.", + }); } return body.sessionToken; @@ -644,6 +908,22 @@ const splitHeaderTokens = (value: string | null) => .filter((token) => token.length > 0) .toSorted(); +const assertBrowserApiCorsHeaders = (headers: Headers) => { + assert.equal(headers.get("access-control-allow-origin"), "*"); + assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-methods")), [ + "GET", + "OPTIONS", + "POST", + ]); + assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-headers")), [ + "authorization", + "b3", + "content-type", + "traceparent", + ]); +}; +const crossOriginClientOrigin = "http://remote-client.test:3773"; + const getWsServerUrl = ( pathname = "", options?: { authenticated?: boolean; credential?: string }, @@ -765,6 +1045,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("includes CORS headers on public environment descriptor responses", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* Effect.promise(() => + fetch(url, { + headers: { + origin: crossOriginClientOrigin, + }, + }), + ); + const body = (yield* Effect.promise(() => + response.json(), + )) as typeof testEnvironmentDescriptor; + + assert.equal(response.status, 200); + assertBrowserApiCorsHeaders(response.headers); + assert.deepEqual(body, testEnvironmentDescriptor); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("reports unauthenticated session state without requiring auth", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -888,6 +1190,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("includes CORS headers on remote auth success responses", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const origin = crossOriginClientOrigin; + const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession( + defaultDesktopBootstrapToken, + { + headers: { origin }, + }, + ); + + assert.equal(bootstrapResponse.status, 200); + assertBrowserApiCorsHeaders(bootstrapResponse.headers); + assert.equal(bootstrapBody.authenticated, true); + assert.equal(typeof bootstrapBody.sessionToken, "string"); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* Effect.promise(() => + fetch(sessionUrl, { + headers: { + authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, + origin, + }, + }), + ); + const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { + readonly authenticated: boolean; + readonly sessionMethod?: string; + }; + + assert.equal(sessionResponse.status, 200); + assertBrowserApiCorsHeaders(sessionResponse.headers); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "bearer-session-token"); + + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const wsTokenResponse = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "POST", + headers: { + authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, + origin, + }, + }), + ); + const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as { + readonly token: string; + }; + + assert.equal(wsTokenResponse.status, 200); + assertBrowserApiCorsHeaders(wsTokenResponse.headers); + assert.equal(typeof wsTokenBody.token, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "responds to remote auth websocket-token preflight requests with authorization CORS headers", () => @@ -899,7 +1257,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { fetch(wsTokenUrl, { method: "OPTIONS", headers: { - origin: "http://192.168.86.35:3773", + origin: crossOriginClientOrigin, "access-control-request-method": "POST", "access-control-request-headers": "authorization", }, @@ -907,18 +1265,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(response.status, 204); - assert.equal(response.headers.get("access-control-allow-origin"), "*"); - assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ - "GET", - "OPTIONS", - "POST", - ]); - assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [ - "authorization", - "b3", - "content-type", - "traceparent", - ]); + assertBrowserApiCorsHeaders(response.headers); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -931,7 +1278,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { fetch(wsTokenUrl, { method: "POST", headers: { - origin: "http://192.168.86.35:3773", + origin: crossOriginClientOrigin, }, }), ); @@ -940,7 +1287,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }; assert.equal(response.status, 401); - assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assertBrowserApiCorsHeaders(response.headers); assert.equal(body.error, "Authentication required."); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1529,6 +1876,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "content-type": "application/json", origin: "http://localhost:5733", }, + // @effect-diagnostics-next-line preferSchemaOverJson:off body: HttpBody.text(JSON.stringify(payload), "application/json"), }); @@ -1574,6 +1922,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ]); assert.deepEqual(upstreamRequests, [ { + // @effect-diagnostics-next-line preferSchemaOverJson:off body: JSON.stringify(payload), contentType: "application/json", }, @@ -1646,6 +1995,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", }, + // @effect-diagnostics-next-line preferSchemaOverJson:off body: HttpBody.text(JSON.stringify(payload), "application/json"), }); @@ -1737,6 +2087,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.removeKeybinding", () => + Effect.gen(function* () { + const rule: KeybindingRule = { + command: "terminal.toggle", + key: "ctrl+k", + }; + const resolved: ResolvedKeybindingRule = { + command: "terminal.toggle", + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + removeKeybindingRule: () => Effect.succeed([resolved]), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverRemoveKeybinding](rule)), + ); + + assert.deepEqual(response.issues, []); + assert.deepEqual(response.keybindings, [resolved]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1761,13 +2147,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertTrue(result._tag === "Failure"); - assertInclude(String(result.failure), "SocketOpenError"); + const failureMessage = String(result.failure); + assertTrue( + failureMessage.includes("SocketOpenError") || failureMessage.includes("SocketCloseError"), + ); + assertTrue( + failureMessage.includes("Unauthorized") || + failureMessage.includes("An error occurred during Open"), + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { - const providers = [] as const; + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ] as const; const changeEvent = { keybindings: [], issues: [], @@ -1817,14 +2224,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(second, { version: 1, type: "keybindingsUpdated", - payload: { issues: [] }, + payload: { keybindings: [], issues: [] }, }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { - const providers = [] as const; + const nextProviders = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ] as const; yield* buildAppUnderTest({ layers: { @@ -1837,7 +2258,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, providerRegistry: { getProviders: Effect.succeed([]), - streamChanges: Stream.succeed(providers), + streamChanges: Stream.succeed(nextProviders), }, }, }); @@ -1851,10 +2272,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const [first, second] = Array.from(events); assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, []); + } assert.deepEqual(second, { version: 1, type: "providerStatuses", - payload: { providers }, + payload: { providers: nextProviders }, }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1879,7 +2303,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { version: 1 as const, sequence: 2, type: "ready" as const, - payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, + payload: { at: "2026-01-01T00:00:00.000Z", environment: testEnvironmentDescriptor }, }); yield* buildAppUnderTest({ @@ -1938,6 +2362,63 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-search-gitignored-", + }); + yield* fs.writeFileString(path.join(workspaceDir, ".gitignore"), ".venv/\n"); + yield* fs.makeDirectory(path.join(workspaceDir, ".venv", "lib"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, ".venv", "lib", "ignored-search-target.ts"), + "export const ignored = true;", + ); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "tracked.ts"), + "export const ok = 1;", + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + listWorkspaceFiles: () => + Effect.succeed({ + paths: ["src/tracked.ts"], + truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + filterIgnoredPaths: (_cwd, relativePaths) => + Effect.succeed( + relativePaths.filter((relativePath) => !relativePath.startsWith(".venv/")), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "ignored-search-target", + limit: 10, + }), + ), + ); + + assert.equal(response.entries.length, 0); + assert.equal(response.truncated, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries errors", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1987,6 +2468,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-create-" }); + const missingWorkspaceRoot = path.join(parentDir, "nested", "new-project"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-missing-root"), + projectId: ProjectId.make("project-create-missing-root"), + title: "New Project", + workspaceRoot: missingWorkspaceRoot, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ), + ); + const stat = yield* fs.stat(missingWorkspaceRoot); + + assert.isAtLeast(response.sequence, 0); + assert.equal(stat.type, "Directory"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -2019,8 +2534,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let openedInput: { cwd: string; editor: EditorId } | null = null; yield* buildAppUnderTest({ layers: { - open: { - openInEditor: (input) => + externalLauncher: { + launchEditor: (input) => Effect.sync(() => { openedInput = input; }), @@ -2044,11 +2559,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc shell.openInEditor errors", () => Effect.gen(function* () { - const openError = new OpenError({ message: "Editor command not found: cursor" }); + const externalLauncherError = new ExternalLauncherError({ + message: "Editor command not found: cursor", + }); yield* buildAppUnderTest({ layers: { - open: { - openInEditor: () => Effect.fail(openError), + externalLauncher: { + launchEditor: () => Effect.fail(externalLauncherError), }, }, }); @@ -2063,7 +2580,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ).pipe(Effect.result), ); - assertFailure(result, openError); + assertFailure(result, externalLauncherError); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -2078,9 +2595,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2094,9 +2611,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2177,16 +2694,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktreePath: null, }), }, - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled", - branch: "main", - upstreamBranch: "origin/main", + refName: "main", + upstreamRef: "origin/main", }), - listBranches: () => + listRefs: () => Effect.succeed({ - branches: [ + refs: [ { name: "main", current: true, @@ -2195,18 +2712,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, ], isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, }), createWorktree: () => Effect.succeed({ - worktree: { path: "/tmp/wt", branch: "feature/demo" }, + worktree: { path: "/tmp/wt", refName: "feature/demo" }, }), removeWorktree: () => Effect.void, - createBranch: (input) => Effect.succeed({ branch: input.branch }), - checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), - initRepo: () => Effect.void, + createRef: (input) => Effect.succeed({ refName: input.refName }), + switchRef: (input) => Effect.succeed({ refName: input.refName }), + }, + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), }, }, }); @@ -2214,13 +2733,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const pull = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), ); assert.equal(pull.status, "pulled"); const refreshedStatus = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), + client[WS_METHODS.vcsRefreshStatus]({ cwd: "/tmp/repo" }), ), ); assert.equal(refreshedStatus.isRepo, true); @@ -2264,27 +2783,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(prepared.branch, "feature/demo"); - const branches = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }), - ), + const refs = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsListRefs]({ cwd: "/tmp/repo" })), ); - assert.equal(branches.branches[0]?.name, "main"); + assert.equal(refs.refs[0]?.name, "main"); const worktree = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateWorktree]({ + client[WS_METHODS.vcsCreateWorktree]({ cwd: "/tmp/repo", - branch: "main", + refName: "main", path: null, }), ), ); - assert.equal(worktree.worktree.branch, "feature/demo"); + assert.equal(worktree.worktree.refName, "feature/demo"); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRemoveWorktree]({ + client[WS_METHODS.vcsRemoveWorktree]({ cwd: "/tmp/repo", path: "/tmp/wt", }), @@ -2293,25 +2810,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateBranch]({ + client[WS_METHODS.vcsCreateRef]({ cwd: "/tmp/repo", - branch: "feature/new", + refName: "feature/new", }), ), ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCheckout]({ + client[WS_METHODS.vcsSwitchRef]({ cwd: "/tmp/repo", - branch: "main", + refName: "main", }), ), ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitInit]({ + client[WS_METHODS.vcsInit]({ cwd: "/tmp/repo", }), ), @@ -2331,7 +2848,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.fail(gitError), }, gitManager: { @@ -2350,9 +2867,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2371,9 +2888,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2388,7 +2905,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })).pipe( Effect.result, ), ); @@ -2427,9 +2944,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2448,9 +2965,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2485,12 +3002,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled" as const, - branch: "main", - upstreamBranch: "origin/main", + refName: "main", + upstreamRef: "origin/main", }), }, gitManager: { @@ -2499,9 +3016,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2519,11 +3036,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const wsUrl = yield* getWsServerUrl("/ws"); - const startedAt = Date.now(); + const startedAt = yield* Clock.currentTimeMillis; const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), ); - const elapsedMs = Date.now() - startedAt; + const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; assert.equal(result.status, "pulled"); assertTrue(elapsedMs < 1_000); @@ -2536,15 +3053,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2585,7 +3105,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const wsUrl = yield* getWsServerUrl("/ws"); - const startedAt = Date.now(); + const startedAt = yield* Clock.currentTimeMillis; yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitRunStackedAction]({ @@ -2595,7 +3115,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Stream.runCollect), ), ); - const elapsedMs = Date.now() - startedAt; + const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; assertTrue(elapsedMs < 1_000); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -2609,6 +3129,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, @@ -2618,9 +3141,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.andThen( Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2679,7 +3202,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { - const now = new Date().toISOString(); + const now = "2026-01-01T00:00:00.000Z"; const snapshot = { snapshotSequence: 1, updatedAt: now, @@ -2748,11 +3271,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const wsUrl = yield* getWsServerUrl("/ws"); - const snapshotResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), - ); - assert.equal(snapshotResult.snapshotSequence, 1); - const dispatchResult = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ @@ -2797,16 +3315,44 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("enriches replayed project events with repository identity metadata", () => + it.effect("routes websocket rpc orchestration shell snapshot errors", () => Effect.gen(function* () { - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:T3Tools/t3code.git", - }, - displayName: "T3Tools/t3code", + const projectionError = new PersistenceSqlError({ + operation: "ProjectionSnapshotQuery.getShellSnapshot:test", + detail: "failed to read projection shell snapshot", + }); + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getShellSnapshot: () => Effect.fail(projectionError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe(Stream.runCollect), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); + assertTrue(result.failure.cause instanceof Error); + assert.include(result.failure.cause.message, projectionError.message); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("enriches replayed project events with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", provider: "github", owner: "T3Tools", name: "t3code", @@ -2864,21 +3410,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("closes thread terminals after a successful archive command", () => + it.effect("stops the provider session and closes thread terminals after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); - const closeInputs: Array[0]> = []; + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; yield* buildAppUnderTest({ layers: { terminalManager: { close: (input) => Effect.sync(() => { - closeInputs.push(input); + effects.push(`terminal.close:${input.threadId}`); }), }, orchestrationEngine: { - dispatch: () => Effect.succeed({ sequence: 8 }), + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), }, }, }); @@ -2894,8 +3467,363 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(dispatchResult.sequence, 8); - assert.deepEqual(closeInputs, [{ threadId }]); + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + const sessionStopCommand = dispatchedCommands[1]; + assert.equal(sessionStopCommand?.type, "thread.session.stop"); + if (sessionStopCommand?.type === "thread.session.stop") { + assert.equal(sessionStopCommand.threadId, threadId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("checks session status before archiving removes the thread from active lookups", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-precheck"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; + let archived = false; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.archive") { + archived = true; + } + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.sync(() => { + effects.push(`query:thread-shell:${archived ? "archived" : "active"}`); + return archived + ? Option.none() + : Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-precheck"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "query:thread-shell:active", + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives without dispatching session stop when the thread has no session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-no-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-no-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "archives without dispatching session stop when the thread session is already stopped", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stopped-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stopped-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-failure"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "simulated archive stop failure", + }), + ); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-failure"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop defects", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-defect"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.die(new Error("simulated archive stop defect")); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-defect"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -2904,14 +3832,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => + const refreshStatus = vi.fn((_: string) => Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "t3code/bootstrap-refName", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, }), ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + ); const runForThread = vi.fn( (_: Parameters[0]) => Effect.succeed({ @@ -2925,9 +3872,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, + vcsStatusBroadcaster: { + refreshStatus, + }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -2942,7 +3892,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); const response = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => @@ -2973,7 +3923,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -2995,8 +3945,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", - branch: "main", - newBranch: "t3code/bootstrap-branch", + refName: "main", + newRefName: "t3code/bootstrap-refName", path: null, }); assert.deepEqual(runForThread.mock.calls[0]?.[0], { @@ -3005,6 +3955,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", worktreePath: "/tmp/bootstrap-worktree", }); + assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); const setupActivities = dispatchedCommands.filter( (command): command is Extract => @@ -3025,22 +3976,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => - Effect.fail(new Error("pty unavailable")), + Effect.fail(new ProjectSetupScriptRunnerError({ message: "pty unavailable" })), ); yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3057,7 +4009,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); const response = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => @@ -3088,7 +4040,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3118,13 +4070,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3140,7 +4093,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3173,7 +4126,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); const response = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => @@ -3204,7 +4157,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3236,13 +4189,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.die(new Error("worktree exploded")), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("worktree exploded")), ); yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3256,7 +4210,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); - const createdAt = new Date().toISOString(); + const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => @@ -3287,7 +4241,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: false, }, @@ -3306,234 +4260,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", - () => - Effect.gen(function* () { - const now = new Date().toISOString(); - const threadId = ThreadId.make("thread-1"); - let replayCursor: number | null = null; - const makeEvent = (sequence: number): OrchestrationEvent => - ({ - sequence, - eventId: `event-${sequence}`, - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.reverted", - payload: { - threadId, - turnCount: sequence, - }, - }) as OrchestrationEvent; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 1, - }), - readEvents: (fromSequenceExclusive) => { - replayCursor = fromSequenceExclusive; - return Stream.make(makeEvent(2), makeEvent(3)); - }, - streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(3), - Stream.runCollect, - ), - ), - ); - - assert.equal(replayCursor, 1); - assert.deepEqual( - Array.from(events).map((event) => event.sequence), - [2, 3, 4], - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("enriches replayed project events only once before streaming them to subscribers", () => - Effect.gen(function* () { - let resolveCalls = 0; - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/t3code.git", - }, - displayName: "t3tools/t3code", - provider: "github" as const, - owner: "t3tools", - name: "t3code", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 0, - }), - readEvents: () => - Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-06T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.meta-updated", - payload: { - projectId: defaultProjectId, - title: "Replayed Project", - updatedAt: "2026-04-06T00:00:00.000Z", - }, - } satisfies Extract), - streamDomainEvents: Stream.empty, - }, - repositoryIdentityResolver: { - resolve: () => { - resolveCalls += 1; - return Effect.succeed(repositoryIdentity); - }, - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(1), - Stream.runCollect, - ), - ), - ); - - const event = Array.from(events)[0]; - assert.equal(resolveCalls, 1); - assert.equal(event?.type, "project.meta-updated"); - assert.deepEqual( - event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("enriches subscribed project meta updates with repository identity metadata", () => - Effect.gen(function* () { - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "upstream", - remoteUrl: "git@github.com:T3Tools/t3code.git", - }, - displayName: "T3Tools/t3code", - provider: "github", - owner: "T3Tools", - name: "t3code", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 0, - }), - streamDomainEvents: Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-05T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.meta-updated", - payload: { - projectId: defaultProjectId, - title: "Renamed Project", - updatedAt: "2026-04-05T00:00:00.000Z", - }, - } satisfies Extract), - }, - repositoryIdentityResolver: { - resolve: () => Effect.succeed(repositoryIdentity), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(1), - Stream.runCollect, - ), - ), - ); - - const event = Array.from(events)[0]; - assert.equal(event?.type, "project.meta-updated"); - assert.deepEqual( - event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("routes websocket rpc orchestration.getSnapshot errors", () => - Effect.gen(function* () { - yield* buildAppUnderTest({ - layers: { - projectionSnapshotQuery: { - getSnapshot: () => - Effect.fail( - new PersistenceSqlError({ - operation: "ProjectionSnapshotQuery.getSnapshot", - detail: "projection unavailable", - }), - ), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe( - Effect.result, - ), - ); - - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); - assertInclude(result.failure.message, "Failed to load orchestration snapshot"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc terminal methods", () => Effect.gen(function* () { const snapshot = { @@ -3546,7 +4272,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { history: "", exitCode: null, exitSignal: null, - updatedAt: new Date().toISOString(), + updatedAt: "2026-01-01T00:00:00.000Z", }; yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 23c53ad07fd..c6780559204 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,7 +1,8 @@ -import { Effect, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { attachmentsRouteLayer, otlpTracesProxyRouteLayer, @@ -9,45 +10,57 @@ import { serverEnvironmentRouteLayer, staticAndDevRouteLayer, browserApiCorsLayer, -} from "./http"; -import { fixPath } from "./os-jank"; -import { websocketRpcRouteLayer } from "./ws"; -import { OpenLive } from "./open"; -import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite"; -import { ServerLifecycleEventsLive } from "./serverLifecycleEvents"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; -import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; -import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; -import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; -import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; -import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; -import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { GitCoreLive } from "./git/Layers/GitCore"; -import { GitHubCliLive } from "./git/Layers/GitHubCli"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; -import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; -import { TerminalManagerLive } from "./terminal/Layers/Manager"; -import { GitManagerLive } from "./git/Layers/GitManager"; -import { KeybindingsLive } from "./keybindings"; -import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; -import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; -import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; -import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; -import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; -import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; -import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; -import { ServerSettingsLive } from "./serverSettings"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; -import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; -import { ObservabilityLive } from "./observability/Layers/Observability"; -import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +} from "./http.ts"; +import { fixPath } from "./os-jank.ts"; +import { websocketRpcRouteLayer } from "./ws.ts"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; +import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; +import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; +import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitLabCli from "./sourceControl/GitLabCli.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; +import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import * as GitManager from "./git/GitManager.ts"; +import { KeybindingsLive } from "./keybindings.ts"; +import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; +import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; +import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; +import { ServerSettingsLive } from "./serverSettings.ts"; +import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; +import { ObservabilityLive } from "./observability/Layers/Observability.ts"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; import { authBearerBootstrapRouteLayer, authBootstrapRouteLayer, @@ -59,27 +72,32 @@ import { authPairingCredentialRouteLayer, authSessionRouteLayer, authWebSocketTokenRouteLayer, -} from "./auth/http"; -import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; -import { ServerAuthLive } from "./auth/Layers/ServerAuth"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +} from "./auth/http.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; +import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, persistServerRuntimeState, -} from "./serverRuntimeState"; +} from "./serverRuntimeState.ts"; import { orchestrationDispatchRouteLayer, orchestrationSnapshotRouteLayer, -} from "./orchestration/http"; +} from "./orchestration/http.ts"; +import * as NetService from "@t3tools/shared/Net"; +import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { - const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY")); + const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); return BunPTY.layer; } else { - const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY")); + const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); return NodePTY.layer; } }), @@ -126,67 +144,91 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), + Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(RuntimeReceiptBusLive), ); -const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive), +const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), ); -const ProviderLayerLive = Layer.unwrap( - Effect.gen(function* () { - const { providerEventLogPath } = yield* ServerConfig; - const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "native", - }); - const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { - stream: "canonical", - }); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); - const codexAdapterLayer = makeCodexAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const claudeAdapterLayer = makeClaudeAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( - Layer.provide(codexAdapterLayer), - Layer.provide(claudeAdapterLayer), - Layer.provideMerge(providerSessionDirectoryLayer), - ); - return makeProviderServiceLive( - canonicalEventLogger ? { canonicalEventLogger } : undefined, - ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); - }), +// `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 +// 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( + Layer.provide(ProviderAdapterRegistryLive), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const GitManagerLayerLive = GitManagerLive.pipe( +const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), +); + +const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), + ), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), +); + +const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(RoutingTextGenerationLive), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge(TextGeneration.layer), ); const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), - Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitVcsDriver.layer), +); + +const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( + Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provideMerge(GitLayerLive), +); + +const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), +); + +const VcsLayerLive = Layer.empty.pipe( + Layer.provideMerge(VcsProjectConfig.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), + Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(SourceControlRepositoryServiceLayerLive), + Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), +); + +const CheckpointingLayerLive = Layer.empty.pipe( + Layer.provideMerge(CheckpointDiffQueryLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(VcsDriverRegistryLayerLive), +); + +const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLayerLive), +); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), + WorkspaceEntriesLayerLive, + WorkspaceFileSystemLayerLive, ); const AuthLayerLive = ServerAuthLive.pipe( @@ -194,27 +236,57 @@ const AuthLayerLive = ServerAuthLive.pipe( Layer.provide(ServerSecretStoreLive), ); -const RuntimeDependenciesLive = ReactorLayerLive.pipe( +const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( + Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(OrchestrationLayerLive), +); + +const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), - Layer.provideMerge(OrchestrationLayerLive), - Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(VcsLayerLive), + Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), + // The instance registry is the new routing keystone — text generation, + // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` + // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; + // `providerInstances` hydration merges `settings.providers.` + // with explicit `providerInstances` entries on boot. + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // Shared native/canonical NDJSON writers used by both the per-instance + // drivers (native stream, written from inside each `Adapter`) and + // `ProviderService` (canonical stream, written after event normalization). + // Provided once at the runtime level so every consumer sees the same + // logger instances. + Layer.provideMerge(ProviderEventLoggersLive), + // `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(OpenCodeRuntimeLive), Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), +); +const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. + Layer.provideMerge(ProcessDiagnostics.layer), + Layer.provideMerge(ProcessResourceMonitor.layer), + Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsServiceLayerLive), - Layer.provideMerge(OpenLive), + Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(ServerLifecycleEventsLive), + Layer.provide(NetService.layer), ); const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( @@ -264,7 +336,7 @@ export const makeServerLayer = Layer.unwrap( return; } - const state = makePersistedServerRuntimeState({ + const state = yield* makePersistedServerRuntimeState({ config, port: address.port, }); @@ -276,6 +348,57 @@ export const makeServerLayer = Layer.unwrap( () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), ), ); + const tailscaleServeLayer = config.tailscaleServeEnabled + ? Layer.effectDiscard( + Effect.acquireRelease( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return null; + } + + const localPort = address.port; + return yield* ensureTailscaleServe({ + localPort, + servePort: config.tailscaleServePort, + localHost: "127.0.0.1", + }).pipe( + Effect.as({ localPort, servePort: config.tailscaleServePort }), + Effect.tap(() => + Effect.logInfo("Tailscale Serve configured", { + localPort, + servePort: config.tailscaleServePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to configure Tailscale Serve", { + cause, + localPort, + servePort: config.tailscaleServePort, + }).pipe(Effect.as(null)), + ), + ); + }), + (configured) => + configured + ? disableTailscaleServe({ servePort: configured.servePort }).pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve disabled", { + servePort: configured.servePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { + cause, + servePort: configured.servePort, + }), + ), + ) + : Effect.void, + ), + ) + : Layer.empty; const serverApplicationLayer = Layer.mergeAll( HttpRouter.serve(makeRoutesLayer, { @@ -283,6 +406,7 @@ export const makeServerLayer = Layer.unwrap( }), httpListeningLayer, runtimeStateLayer, + tailscaleServeLayer, ); return serverApplicationLayer.pipe( @@ -290,14 +414,11 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), ); }), ); // Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer. -export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect< - never, - any, - ServerConfig ->; +export const runServer = Layer.launch(makeServerLayer); diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 47e24c1b87c..14fbba9e238 100644 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -1,7 +1,8 @@ import { EnvironmentId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertTrue } from "@effect/vitest/utils"; -import { Effect, Option } from "effect"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; @@ -37,7 +38,7 @@ it.effect( version: 1, type: "ready", payload: { - at: new Date().toISOString(), + at: "2026-01-01T00:00:00.000Z", environment, }, }) diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 145d1cbaa4e..88661b1593a 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -1,5 +1,10 @@ import type { ServerLifecycleStreamEvent } from "@t3tools/contracts"; -import { Effect, Layer, PubSub, Ref, Context, Stream } from "effect"; +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 Context from "effect/Context"; +import * as Stream from "effect/Stream"; type LifecycleEventInput = | Omit, "sequence"> diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index ea098dcbbea..a7cb1d6a26e 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,6 +1,9 @@ -import { Effect, Logger, References, Layer } from "effect"; +import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Layer from "effect/Layer"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index c3159cc9d8c..33b9abcb98a 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,20 +1,33 @@ -import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Option, Ref } from "effect"; +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 Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerConfig } from "./config.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { getAutoBootstrapDefaultModelSelection, launchStartupHeartbeat, makeCommandGate, + resolveAutoBootstrapWelcomeTargets, + resolveWelcomeBase, ServerRuntimeStartupError, } from "./serverRuntimeStartup.ts"; it("uses the canonical Codex default for auto-bootstrapped model selection", () => { assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, }); }); @@ -69,7 +82,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa yield* launchStartupHeartbeat.pipe( Effect.provideService(ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Deferred.await(releaseCounts).pipe( Effect.as({ @@ -78,8 +95,12 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService, { record: () => Effect.void, @@ -89,3 +110,115 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), ); + +it.effect("resolveWelcomeBase derives cwd and project name from server config", () => + Effect.gen(function* () { + const welcome = yield* resolveWelcomeBase.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + } as never), + ); + + assert.deepStrictEqual(welcome, { + cwd: "/tmp/startup-project", + projectName: "startup-project", + }); + }), +); + +it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and thread ids", () => { + const bootstrapProjectId = ProjectId.make("project-startup-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-startup-bootstrap"); + + return Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: bootstrapProjectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }), + ), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + } satisfies OrchestrationEngineShape), + Effect.provide(NodeServices.layer), + ); + + assert.deepStrictEqual(targets, { + bootstrapProjectId, + bootstrapThreadId, + }); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); + }); +}); + +it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + } satisfies OrchestrationEngineShape), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 441df3395d9..9ec536105c8 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,43 +1,44 @@ import { CommandId, - DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, type ModelSelection, ProjectId, + ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; -import { - Data, - Deferred, - Effect, - Exit, - Layer, - Option, - Path, - Queue, - Ref, - Scope, - Context, - Console, -} from "effect"; - -import { ServerConfig } from "./config"; -import { Keybindings } from "./keybindings"; -import { Open } from "./open"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerSettingsService } from "./serverSettings"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; +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 Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Context from "effect/Context"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; + +import { ServerConfig } from "./config.ts"; +import { Keybindings } from "./keybindings.ts"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, isWildcardHost, issueHeadlessServeAccessInfo, -} from "./startupAccess"; +} from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ readonly message: string; @@ -153,11 +154,22 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( ); export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, }); -const autoBootstrapWelcome = Effect.gen(function* () { +export const resolveWelcomeBase = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; + + return { + cwd: serverConfig.cwd, + projectName, + } as const; +}); + +export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; @@ -175,7 +187,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { let nextProjectDefaultModelSelection: ModelSelection; if (Option.isNone(existingProject)) { - const createdAt = new Date().toISOString(); + const createdAt = DateTime.formatIso(yield* DateTime.now); nextProjectId = ProjectId.make(crypto.randomUUID()); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); @@ -197,7 +209,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { const existingThreadId = yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); if (Option.isNone(existingThreadId)) { - const createdAt = new Date().toISOString(); + const createdAt = DateTime.formatIso(yield* DateTime.now); const createdThreadId = ThreadId.make(crypto.randomUUID()); yield* orchestrationEngine.dispatch({ type: "thread.create", @@ -221,12 +233,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { }); } - const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); - const projectName = segments[segments.length - 1] ?? "project"; - return { - cwd: serverConfig.cwd, - projectName, ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), } as const; @@ -254,9 +261,9 @@ const maybeOpenBrowser = (target: string) => if (serverConfig.noBrowser) { return; } - const { openBrowser } = yield* Open; + const externalLauncher = yield* ExternalLauncher.ExternalLauncher; - yield* openBrowser(target).pipe( + yield* externalLauncher.launchBrowser(target).pipe( Effect.catch(() => Effect.logInfo("browser auto-open unavailable", { hint: `Open ${target} in your browser.`, @@ -271,10 +278,11 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); -const makeServerRuntimeStartup = Effect.gen(function* () { +export const makeServerRuntimeStartup = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; + const providerSessionReaper = yield* ProviderSessionReaper; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; @@ -319,18 +327,19 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: starting orchestration reactors"); yield* runStartupPhase( "reactors.start", - orchestrationReactor.start().pipe(Scope.provide(reactorScope)), + Effect.gen(function* () { + yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + }), ); - yield* Effect.logDebug("startup phase: preparing welcome payload"); - const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; + yield* Effect.logDebug("startup phase: preparing welcome payload"); yield* Effect.logDebug("startup phase: publishing welcome event", { environmentId: environment.environmentId, - cwd: welcome.cwd, - projectName: welcome.projectName, - bootstrapProjectId: welcome.bootstrapProjectId, - bootstrapThreadId: welcome.bootstrapThreadId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, }); yield* runStartupPhase( "welcome.publish", @@ -339,10 +348,47 @@ const makeServerRuntimeStartup = Effect.gen(function* () { type: "welcome", payload: { environment, - ...welcome, + ...welcomeBase, }, }), ); + + if (serverConfig.autoBootstrapProjectFromCwd) { + yield* Effect.forkScoped( + runStartupPhase( + "welcome.autobootstrap", + Effect.gen(function* () { + const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets; + if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { + return; + } + + yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + bootstrapProjectId: bootstrapTargets.bootstrapProjectId, + bootstrapThreadId: bootstrapTargets.bootstrapThreadId, + }); + yield* lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapTargets, + }, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("startup auto-bootstrap welcome failed", { + cause, + }), + ), + ), + ), + ); + } }).pipe( Effect.annotateSpans({ "server.mode": serverConfig.mode, @@ -376,7 +422,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { version: 1, type: "ready", payload: { - at: new Date().toISOString(), + at: DateTime.formatIso(yield* DateTime.now), environment: yield* serverEnvironment.getDescriptor, }, }), diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 00c83844682..996f9a2bfc9 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,7 +1,12 @@ -import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; -import { type ServerConfigShape } from "./config"; -import { formatHostForUrl, isWildcardHost } from "./startupAccess"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { type ServerConfigShape } from "./config.ts"; +import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ version: Schema.Literal(1), @@ -29,28 +34,23 @@ const runtimeOriginForConfig = ( export const makePersistedServerRuntimeState = (input: { readonly config: Pick; readonly port: number; -}): PersistedServerRuntimeState => ({ - version: 1, - pid: process.pid, - ...(input.config.host ? { host: input.config.host } : {}), - port: input.port, - origin: runtimeOriginForConfig(input.config, input.port), - startedAt: new Date().toISOString(), -}); +}): Effect.Effect => + Effect.map(DateTime.now, (now) => ({ + version: 1, + pid: process.pid, + ...(input.config.host ? { host: input.config.host } : {}), + port: input.port, + origin: runtimeOriginForConfig(input.config, input.port), + startedAt: DateTime.formatIso(now), + })); export const persistServerRuntimeState = (input: { readonly path: string; readonly state: PersistedServerRuntimeState; }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`; - return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe( - Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)), - Effect.flatMap(() => fs.rename(tempPath, input.path)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), - ); + writeFileStringAtomically({ + filePath: input.path, + contents: `${JSON.stringify(input.state)}\n`, }); export const clearPersistedServerRuntimeState = (path: string) => diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 289bc689617..7af27f0b7cf 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,9 +1,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + ServerSettings, + ServerSettingsPatch, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Schema } from "effect"; -import { ServerConfig } from "./config"; -import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; +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 Schema from "effect/Schema"; +import { ServerConfig } from "./config.ts"; +import { ServerSettingsLive, ServerSettingsService } from "./serverSettings.ts"; const makeServerSettingsLayer = () => ServerSettingsLive.pipe( @@ -28,22 +39,40 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual( decodePatch({ textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }), { textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }, ); }), ); + it.effect( + "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", + () => + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(ServerSettings); + + const decoded = decode({ + textGenerationModelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.4-mini", + options: { reasoningEffort: "low" }, + }, + }); + + assert.deepEqual(decoded.textGenerationModelSelection, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4-mini", + options: [{ id: "reasoningEffort", value: "low" }], + }); + }), + ); + it.effect("deep merges nested settings updates without dropping siblings", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -60,12 +89,16 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: true, - }, + options: createModelSelection( + ProviderInstanceId.make("codex"), + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, }, }); @@ -76,9 +109,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, textGenerationModelSelection: { - options: { - fastMode: false, - }, + options: [{ id: "fastMode", value: false }], }, }); @@ -86,21 +117,27 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/codex", homePath: "/Users/julius/.codex", + shadowHomePath: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { enabled: true, binaryPath: "/usr/local/bin/claude", + homePath: "", customModels: ["claude-custom"], + launchArgs: "", }); - assert.deepEqual(next.textGenerationModelSelection, { - provider: "codex", - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: { - reasoningEffort: "high", - fastMode: false, - }, - }); + assert.deepEqual( + next.textGenerationModelSelection, + createModelSelection( + ProviderInstanceId.make("codex"), + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ], + ), + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -111,11 +148,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { // Start with Claude text generation selection yield* serverSettings.updateSettings({ textGenerationModelSelection: { - provider: "claudeAgent", + instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", - options: { - effort: "high", - }, + options: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "high" }], + ).options!, }, }); @@ -123,21 +162,174 @@ it.layer(NodeServices.layer)("server settings", (it) => { // cause the update to lose the selected model. const next = yield* serverSettings.updateSettings({ textGenerationModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", - options: { - reasoningEffort: "high", + options: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]).options!, + }, + }); + + assert.deepEqual( + next.textGenerationModelSelection, + createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]), + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves custom provider instance text generation selections", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("claude_openrouter")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: { customModels: ["openai/gpt-5.5"] }, }, }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claude_openrouter"), + model: "openai/gpt-5.5", + }, }); assert.deepEqual(next.textGenerationModelSelection, { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "high", + instanceId: ProviderInstanceId.make("claude_openrouter"), + model: "openai/gpt-5.5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect( + "uses explicit provider instance enabled state over legacy provider enabled state", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const instanceId = ProviderInstanceId.make("claude_openrouter"); + + const next = yield* serverSettings.updateSettings({ + providers: { + claudeAgent: { + enabled: false, + }, + }, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: { customModels: ["openai/gpt-5.5"] }, + }, + }, + textGenerationModelSelection: { + instanceId, + model: "openai/gpt-5.5", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId, + model: "openai/gpt-5.5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves enabled text generation selections for non-built-in drivers", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const instanceId = ProviderInstanceId.make("openrouter_text"); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("openrouter"), + enabled: true, + config: { customModels: ["openai/gpt-5.5"] }, + }, + }, + textGenerationModelSelection: { + instanceId, + model: "openai/gpt-5.5", }, }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId, + model: "openai/gpt-5.5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("drops stale text generation options when resetting model selection", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: createModelSelection( + ProviderInstanceId.make("codex"), + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + ).options!, + }, + }); + + const next = yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("replaces provider instance maps when clearing optional fields", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const codexId = ProviderInstanceId.make("codex"); + + yield* serverSettings.updateSettings({ + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Work", + accentColor: "#7c3aed", + enabled: true, + config: { homePath: "~/.codex" }, + }, + }, + }); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Work", + enabled: true, + config: { homePath: "~/.codex" }, + }, + }, + }); + + assert.deepEqual(next.providerInstances[codexId], { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Work", + enabled: true, + config: { homePath: "~/.codex" }, + }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -154,6 +346,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: " /opt/homebrew/bin/claude ", }, + opencode: { + binaryPath: " /opt/homebrew/bin/opencode ", + serverUrl: " http://127.0.0.1:4096 ", + serverPassword: " secret-password ", + }, }, }); @@ -161,11 +358,21 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/codex", homePath: "", + shadowHomePath: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { enabled: true, binaryPath: "/opt/homebrew/bin/claude", + homePath: "", + customModels: [], + launchArgs: "", + }); + assert.deepEqual(next.providers.opencode, { + enabled: true, + binaryPath: "/opt/homebrew/bin/opencode", + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", customModels: [], }); }).pipe(Effect.provide(makeServerSettingsLayer())), @@ -176,12 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsService; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: " ~/Development ", observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }); + assert.equal(next.addProjectBaseDirectory, "~/Development"); assert.deepEqual(next.observability, { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -215,6 +424,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -223,13 +433,20 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, + automaticGitFetchInterval: Duration.seconds(10), }); assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(raw), { + addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -238,8 +455,77 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, + automaticGitFetchInterval: 10_000, }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("stores sensitive provider instance environment values outside settings.json", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const instanceId = ProviderInstanceId.make("codex_personal"); + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-or-secret", sensitive: true }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ], + config: {}, + }, + }, + }); + + assert.deepEqual(next.providerInstances[instanceId]?.environment, [ + { + name: "OPENROUTER_API_KEY", + value: "sk-or-secret", + sensitive: true, + valueRedacted: true, + }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ]); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "sk-or-secret"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(raw).providerInstances.codex_personal.environment, [ + { + name: "OPENROUTER_API_KEY", + value: "", + sensitive: true, + valueRedacted: true, + }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ]); + + const roundTripped = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Codex Personal", + environment: [ + { name: "OPENROUTER_API_KEY", value: "", sensitive: true, valueRedacted: true }, + { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, + ], + config: {}, + }, + }, + }); + + assert.equal( + roundTripped.providerInstances[instanceId]?.environment?.[0]?.value, + "sk-or-secret", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5a708d5c230..5ea2e03813f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -11,37 +11,102 @@ * @module ServerSettings */ import { + DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, + isProviderDriverKind, type ModelSelection, - type ProviderKind, + type ProviderInstanceConfig, + type ProviderInstanceEnvironmentVariable, + ProviderDriverKind, + ProviderInstanceId, ServerSettings, ServerSettingsError, type ServerSettingsPatch, } from "@t3tools/contracts"; -import { - Cache, - Deferred, - Duration, - Effect, - Exit, - FileSystem, - Layer, - Path, - Equal, - PubSub, - Ref, - Schema, - SchemaIssue, - Scope, - Context, - Stream, - Cause, -} from "effect"; +import * as Cache from "effect/Cache"; +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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Equal from "effect/Equal"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as Scope from "effect/Scope"; +import * as Context from "effect/Context"; +import * as Stream from "effect/Stream"; +import * as Cause from "effect/Cause"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; + +const encodeServerSettings = Schema.encodeEffect(ServerSettings); +const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); +const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const normalizeServerSettings = ( + settings: ServerSettings, +): Effect.Effect => + encodeServerSettings(settings).pipe( + Effect.flatMap(decodeServerSettings), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ); + +function providerEnvironmentSecretName(input: { + readonly instanceId: string; + readonly name: string; +}): string { + return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; +} + +function redactProviderEnvironmentVariable( + variable: ProviderInstanceEnvironmentVariable, +): ProviderInstanceEnvironmentVariable { + if (!variable.sensitive) { + const { valueRedacted: _omit, ...rest } = variable; + return rest; + } + return { + ...variable, + value: "", + ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}), + }; +} + +export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { + const providerInstances = Object.fromEntries( + Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ + instanceId, + instance.environment + ? { + ...instance, + environment: instance.environment.map(redactProviderEnvironmentVariable), + } + : instance, + ]), + ); + return { ...settings, providerInstances }; +} export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ @@ -70,9 +135,15 @@ export class ServerSettingsService extends Context.Service< Layer.effect( ServerSettingsService, Effect.gen(function* () { - const currentSettingsRef = yield* Ref.make( - deepMerge(DEFAULT_SERVER_SETTINGS, overrides), - ); + const { automaticGitFetchInterval, ...overridesForMerge } = overrides; + const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); + const initialSettings = yield* normalizeServerSettings({ + ...merged, + ...(automaticGitFetchInterval !== undefined + ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } + : {}), + }); + const currentSettingsRef = yield* Ref.make(initialSettings); return { start: Effect.void, @@ -80,7 +151,8 @@ export class ServerSettingsService extends Context.Service< getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => deepMerge(currentSettings, patch)), + Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), + Effect.flatMap(normalizeServerSettings), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, @@ -90,8 +162,15 @@ export class ServerSettingsService extends Context.Service< } const ServerSettingsJson = fromLenientJson(ServerSettings); +const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; +type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; + +const getLegacyProviderSettings = ( + settings: ServerSettings, + provider: ProviderDriverKind, +): LegacyProviderSettings | undefined => + (settings.providers as Record)[provider]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. @@ -101,27 +180,44 @@ const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; - if (settings.providers[selection.provider].enabled) { + const instanceConfig = settings.providerInstances[selection.instanceId]; + if (instanceConfig !== undefined) { + return (instanceConfig.enabled ?? true) ? settings : fallbackTextGenerationProvider(settings); + } + + if ( + isProviderDriverKind(selection.instanceId) && + getLegacyProviderSettings(settings, selection.instanceId)?.enabled + ) { return settings; } - const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); + return fallbackTextGenerationProvider(settings); +} + +function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { + const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); + const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { - // No providers enabled — return as-is; callers will report the error. return settings; } return { ...settings, textGenerationModelSelection: { - provider: fallback, - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback], - } as ModelSelection, + instanceId: ProviderInstanceId.make(fallback), + model: + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? + DEFAULT_GIT_TEXT_GENERATION_MODEL, + } satisfies ModelSelection, }; } // Values under these keys are compared as a whole — never stripped field-by-field. -const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set(["textGenerationModelSelection"]); +const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ + "automaticGitFetchInterval", + "textGenerationModelSelection", +]); function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { @@ -161,6 +257,7 @@ const makeServerSettings = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig; const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; + const secretStore = yield* ServerSecretStore; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded(); @@ -200,7 +297,7 @@ const makeServerSettings = Effect.gen(function* () { } const raw = yield* readRawConfig; - const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); + const decoded = decodeServerSettingsJsonExit(raw); if (decoded._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, @@ -218,25 +315,161 @@ const makeServerSettings = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); - const writeSettingsAtomically = (settings: ServerSettings) => { - const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; - const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; + const toSettingsError = (detail: string, cause: unknown) => + new ServerSettingsError({ + settingsPath, + detail, + cause, + }); - return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( - Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, settingsPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - detail: "failed to write settings file", - cause, - }), - ), - ); - }; + const materializeProviderEnvironmentSecrets = ( + settings: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const providerInstances: Record = { + ...settings.providerInstances, + }; + for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { + if (!instance.environment) continue; + const environment: ProviderInstanceEnvironmentVariable[] = []; + for (const variable of instance.environment) { + if (!variable.sensitive || !variable.valueRedacted) { + environment.push(variable); + continue; + } + const secret = yield* secretStore + .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) + .pipe( + Effect.mapError((cause) => + toSettingsError( + `failed to read sensitive environment variable ${variable.name}`, + cause, + ), + ), + ); + environment.push({ + ...variable, + value: secret ? textDecoder.decode(secret) : "", + }); + } + providerInstances[instanceId] = { + ...instance, + environment, + } satisfies ProviderInstanceConfig; + } + return { + ...settings, + providerInstances: providerInstances as ServerSettings["providerInstances"], + }; + }); + + const persistProviderEnvironmentSecrets = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const providerInstances: Record = { + ...next.providerInstances, + }; + + const nextSecretKeys = new Set(); + for (const [instanceId, instance] of Object.entries(next.providerInstances)) { + if (!instance.environment) continue; + const environment: ProviderInstanceEnvironmentVariable[] = []; + for (const variable of instance.environment) { + const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); + if (!variable.sensitive) { + yield* secretStore + .remove(secretName) + .pipe( + Effect.mapError((cause) => + toSettingsError(`failed to remove environment secret ${variable.name}`, cause), + ), + ); + environment.push(redactProviderEnvironmentVariable(variable)); + continue; + } + + nextSecretKeys.add(secretName); + if (!variable.valueRedacted) { + if (variable.value.length > 0) { + yield* secretStore + .set(secretName, textEncoder.encode(variable.value)) + .pipe( + Effect.mapError((cause) => + toSettingsError(`failed to persist environment secret ${variable.name}`, cause), + ), + ); + environment.push({ ...variable, value: "", valueRedacted: true }); + } else { + yield* secretStore + .remove(secretName) + .pipe( + Effect.mapError((cause) => + toSettingsError(`failed to remove environment secret ${variable.name}`, cause), + ), + ); + const { valueRedacted: _omit, ...rest } = variable; + environment.push(rest); + } + continue; + } + + environment.push(redactProviderEnvironmentVariable(variable)); + } + providerInstances[instanceId] = { + ...instance, + environment, + } satisfies ProviderInstanceConfig; + } + + for (const [instanceId, instance] of Object.entries(current.providerInstances)) { + for (const variable of instance.environment ?? []) { + if (!variable.sensitive) continue; + const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); + if (nextSecretKeys.has(secretName)) continue; + yield* secretStore + .remove(secretName) + .pipe( + Effect.mapError((cause) => + toSettingsError( + `failed to remove stale environment secret ${variable.name}`, + cause, + ), + ), + ); + } + } + + return { + ...next, + providerInstances: providerInstances as ServerSettings["providerInstances"], + }; + }); + + const writeSettingsAtomically = Effect.fnUntraced( + function* (settings: ServerSettings) { + const sparseSettingsJson = yield* encodeServerSettingsJson( + stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, + ); + + return yield* writeFileStringAtomically({ + filePath: settingsPath, + contents: `${sparseSettingsJson}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + }, + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to write settings file", + cause, + }), + ), + ); const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { @@ -309,31 +542,43 @@ const makeServerSettings = Effect.gen(function* () { return { start, ready: Deferred.await(startedDeferred), - getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)), + getSettings: getSettingsFromCache.pipe( + Effect.flatMap(materializeProviderEnvironmentSecrets), + Effect.map(resolveTextGenerationProvider), + ), updateSettings: (patch) => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath: "", - detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, - cause, - }), - ), + const nextPersisted = yield* persistProviderEnvironmentSecrets( + current, + applyServerSettingsPatch(current, patch), ); + const next = yield* normalizeServerSettings(nextPersisted); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); yield* emitChange(next); - return resolveTextGenerationProvider(next); + const materialized = yield* materializeProviderEnvironmentSecrets(next); + return resolveTextGenerationProvider(materialized); }), ), get streamChanges() { - return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); + return Stream.fromPubSub(changesPubSub).pipe( + Stream.mapEffect((settings) => + materializeProviderEnvironmentSecrets(settings).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning("failed to materialize provider environment secrets", { + detail: error.detail, + }).pipe(Effect.as(settings)), + ), + ), + ), + Stream.map(resolveTextGenerationProvider), + ); }, } satisfies ServerSettingsShape; }); -export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings); +export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe( + Layer.provide(ServerSecretStoreLive), +); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts new file mode 100644 index 00000000000..6958f42fb13 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -0,0 +1,294 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +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 { ChildProcessSpawner } from "effect/unstable/process"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn(); + +const supportLayer = Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockRun, + }), + NodeServices.layer, +); +const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("AzureDevOpsCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add Azure provider", + sourceRefName: "refs/heads/feature/source-control", + targetRefName: "refs/heads/main", + status: "active", + creationDate: "2026-01-02T00:00:00.000Z", + closedDate: null, + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.strictEqual(result.number, 42); + assert.strictEqual(result.title, "Add Azure provider"); + assert.strictEqual(result.baseRefName, "main"); + assert.strictEqual(result.headRefName, "feature/source-control"); + assert.strictEqual(result.state, "open"); + assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); + assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("lists pull requests with Azure status and source branch arguments", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + pullRequestId: 7, + title: "Merged work", + sourceRefName: "refs/heads/feature/merged", + targetRefName: "refs/heads/main", + status: "completed", + closedDate: "2026-01-03T00:00:00.000Z", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7", + }, + }, + }, + ]), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + "feature/merged", + "--status", + "completed", + "--top", + "10", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "repo", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates repositories through Azure Repos", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + name: "repo", + webUrl: "https://dev.azure.com/acme/project/_git/repo", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + project: { + name: "project", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.createRepository({ + cwd: "/repo", + repository: "project/repo", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "project/repo", + url: "https://dev.azure.com/acme/project/_git/repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + "repo", + "--project", + "project", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates pull requests using the body file as the Azure description", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const bodyFile = `/tmp/t3code-azure-devops-cli-.md`; + yield* fileSystem.writeFileString(bodyFile, "Generated body"); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile, + }); + + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", + cwd: "/repo", + args: expect.arrayContaining(["--description", `@${bodyFile}`]), + }), + ); + expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("does not force JSON output on checkout side-effect commands", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + "42", + "--remote-name", + "origin", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts new file mode 100644 index 00000000000..d4a4d69267b --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -0,0 +1,434 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export interface AzureDevOpsRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface AzureDevOpsCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect< + ReadonlyArray, + AzureDevOpsCliError + >; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect< + AzureDevOpsPullRequests.NormalizedAzureDevOpsPullRequestRecord, + AzureDevOpsCliError + >; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly remoteName?: string; + }) => Effect.Effect; +} + +export class AzureDevOpsCli extends Context.Service()( + "t3/source-control/AzureDevOpsCli", +) {} + +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + +function normalizeAzureDevOpsCliError( + operation: "execute", + error: VcsError | unknown, +): AzureDevOpsCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: az") || lower.includes("enoent")) { + return new AzureDevOpsCliError({ + operation, + detail: + "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("az devops login") || + lower.includes("please run az login") || + lower.includes("not logged in") || + lower.includes("authentication failed") || + lower.includes("unauthorized") + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("pull request") && + (lower.includes("not found") || lower.includes("does not exist")) + ) { + return new AzureDevOpsCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new AzureDevOpsCliError({ + operation, + detail: text, + cause: error, + }); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); + return urlMatch?.[1] ?? trimmed; +} + +function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string { + switch (state) { + case "open": + return "active"; + case "closed": + return "abandoned"; + case "merged": + return "completed"; + case "all": + return "all"; + } +} + +const RawAzureDevOpsRepositorySchema = Schema.Struct({ + name: TrimmedNonEmptyString, + webUrl: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + project: Schema.optional( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), +}); + +function normalizeDefaultBranch(value: string | null | undefined): string | null { + const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): AzureDevOpsRepositoryCloneUrls { + const projectName = raw.project?.name.trim(); + return { + nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name, + url: raw.remoteUrl, + sshUrl: raw.sshUrl, + }; +} + +function parseRepositorySpecifier(repository: string): { + readonly project: string | null; + readonly name: string; +} { + const parts = repository + .split("/") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + return { + project: parts.length > 1 ? (parts.at(-2) ?? null) : null, + name: parts.at(-1) ?? repository.trim(), + }; +} + +function decodeAzureDevOpsJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new AzureDevOpsCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +export const make = Effect.fn("makeAzureDevOpsCli")(function* () { + const process = yield* VcsProcess.VcsProcess; + + const execute: AzureDevOpsCliShape["execute"] = (input) => + process + .run({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); + + const executeJson = (input: Parameters[0]) => + execute({ + ...input, + args: [...input.args, "--only-show-errors", "--output", "json"], + }); + + return AzureDevOpsCli.of({ + execute, + listPullRequests: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--source-branch", + SourceControlProvider.sourceBranch(input), + "--status", + toAzureStatus(input.state), + "--top", + String(input.limit ?? 20), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => + AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestListJson(raw), + ).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsCliError({ + operation: "listPullRequests", + detail: `Azure DevOps CLI returned invalid PR list JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getPullRequest: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "show", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => AzureDevOpsPullRequests.decodeAzureDevOpsPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsCliError({ + operation: "getPullRequest", + detail: `Azure DevOps CLI returned invalid pull request JSON: ${AzureDevOpsPullRequests.formatAzureDevOpsJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true", "--repository", input.repository], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getRepositoryCloneUrls", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => { + const repository = parseRepositorySpecifier(input.repository); + // Azure Repos access is governed by project/organization permissions. + // `az repos create` does not expose a per-repository visibility flag, so + // the generic source-control visibility input is intentionally not + // translated into CLI args for this provider. + return executeJson({ + cwd: input.cwd, + args: [ + "repos", + "create", + "--detect", + "true", + "--name", + repository.name, + ...(repository.project ? ["--project", repository.project] : []), + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "createRepository", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "create", + "--only-show-errors", + "--detect", + "true", + "--target-branch", + input.target?.refName ?? input.baseBranch, + "--source-branch", + SourceControlProvider.sourceBranch(input), + "--title", + input.title, + "--description", + `@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "show", "--detect", "true"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson( + raw, + RawAzureDevOpsRepositorySchema, + "getDefaultBranch", + "Azure DevOps CLI returned invalid repository JSON.", + ), + ), + Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--detect", + "true", + "--id", + normalizeChangeRequestId(input.reference), + "--remote-name", + input.remoteName ?? "origin", + ], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(AzureDevOpsCli, make()); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts new file mode 100644 index 00000000000..4ba3777159b --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -0,0 +1,93 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; + +function makeProvider(azure: Partial) { + return AzureDevOpsSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)(azure)), + ); +} + +it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "azure-devops", + number: 42, + title: "Add Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: false, + }); + }), +); + +it.effect("creates Azure DevOps PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it.effect("uses Azure CLI repository detection for default branch lookup", () => + Effect.gen(function* () { + let cwdInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + cwdInput = input.cwd; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(cwdInput, "/repo"); + }), +); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts new file mode 100644 index 00000000000..8d8e081cb89 --- /dev/null +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -0,0 +1,145 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; + +function providerError( + operation: string, + cause: AzureDevOpsCli.AzureDevOpsCliError, +): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "azure-devops", + operation, + detail: cause.detail, + cause, + }); +} + +function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); + + if (input.exitCode !== 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "unauthenticated", + detail: + SourceControlProviderDiscovery.firstSafeAuthLine( + SourceControlProviderDiscovery.combinedAuthOutput(input), + ) ?? "Run `az login` to authenticate Azure CLI.", + }); + } + + if (account !== undefined && account.length > 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "authenticated", + account, + host: "dev.azure.com", + }); + } + + return SourceControlProviderDiscovery.providerAuth({ + status: "unknown", + host: "dev.azure.com", + detail: "Azure CLI account status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + versionArgs: ["--version"], + authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + parseAuth: parseAzureAuth, + installHint: + "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; + +function toChangeRequest(summary: { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: ChangeRequest["updatedAt"]; +}): ChangeRequest { + return { + provider: "azure-devops", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + updatedAt: summary.updatedAt, + isCrossRepository: false, + }; +} + +export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "azure-devops", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return azure + .listPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source !== undefined ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + azure.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return azure + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source !== undefined ? { source } : {}), + ...(input.target !== undefined ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + azure + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + azure + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), + getDefaultBranch: (input) => + azure + .getDefaultBranch({ cwd: input.cwd }) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + azure + .checkoutPullRequest({ + cwd: input.cwd, + reference: input.reference, + ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), + }) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts new file mode 100644 index 00000000000..e93362b8423 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -0,0 +1,611 @@ +import { assert, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as DateTime from "effect/DateTime"; +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 { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import type * as VcsDriver from "../vcs/VcsDriver.ts"; + +const bitbucketPullRequest = { + id: 42, + title: "Add Bitbucket provider", + state: "OPEN", + updated_on: "2026-01-02T00:00:00.000Z", + links: { + html: { + href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + }, + }, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "octocat/t3code", + workspace: { slug: "octocat" }, + }, + }, + destination: { + branch: { name: "main" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, +}; + +const repositoryJson = { + full_name: "pingdotgg/t3code", + links: { + html: { href: "https://bitbucket.org/pingdotgg/t3code" }, + clone: [ + { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" }, + ], + }, + mainbranch: { name: "main" }, +}; + +function makeLayer(input: { + readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + readonly git?: Partial; +}) { + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + ); + const gitMock = { + readConfigValue: vi.fn(() => + Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), + ), + resolvePrimaryRemoteName: vi.fn( + () => Effect.succeed("origin"), + ), + ensureRemote: vi.fn(() => + Effect.succeed("octocat"), + ), + fetchRemoteBranch: vi.fn( + () => Effect.void, + ), + fetchRemoteTrackingBranch: vi.fn( + () => Effect.void, + ), + setBranchUpstream: vi.fn( + () => Effect.void, + ), + switchRef: vi.fn((request) => + Effect.succeed({ refName: request.refName }), + ), + listLocalBranchNames: vi.fn(() => + Effect.succeed([]), + ), + }; + const git = { + ...gitMock, + ...input.git, + } satisfies Partial; + + const driver = { + listRemotes: () => + Effect.succeed({ + remotes: [ + { + name: "origin", + url: "git@bitbucket.org:pingdotgg/t3code.git", + pushUrl: Option.none(), + isPrimary: true, + }, + ], + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }), + } satisfies Partial; + + const layer = BitbucketApi.layer.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ), + ), + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriver.VcsDriverShape, + }), + }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", + T3CODE_BITBUCKET_EMAIL: "user@example.com", + T3CODE_BITBUCKET_API_TOKEN: "token", + }, + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return { execute, git: gitMock, layer }; +} + +it.effect("parses pull request responses from the Bitbucket REST API", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const result = yield* bitbucket.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }); + assert.strictEqual( + execute.mock.calls[0]?.[0].url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests/42", + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("lists pull requests with Bitbucket state and source branch query params", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [ + { + ...bitbucketPullRequest, + id: 7, + state: "MERGED", + source: { + branch: { name: "feature/merged" }, + repository: { full_name: "pingdotgg/t3code" }, + }, + }, + ], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const result = yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "origin:feature/merged", + state: "merged", + limit: 10, + }); + + assert.strictEqual(result[0]?.state, "merged"); + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", + ); + assert.deepStrictEqual(request?.urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + ["q", 'source.branch.name = "feature/merged" AND state = "MERGED"'], + ["state", "MERGED"], + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("lists closed pull requests with both closed Bitbucket states", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "feature/closed", + state: "closed", + limit: 10, + }); + + assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + [ + "q", + 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', + ], + ["state", "DECLINED"], + ["state", "SUPERSEDED"], + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { + const { execute, layer } = makeLayer({ + response: () => + Response.json({ + values: [], + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.listPullRequests({ + cwd: "/repo", + headSelector: "feature/all", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ + ["pagelen", "10"], + ["sort", "-updated_on"], + [ + "q", + 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', + ], + ["state", "OPEN"], + ["state", "MERGED"], + ["state", "DECLINED"], + ["state", "SUPERSEDED"], + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reads repository clone URLs and default branch", () => { + const { layer } = makeLayer({ + response: (request) => + Response.json( + request.url.endsWith("/branching-model") + ? { + development: { + branch: { name: "main" }, + name: "main", + use_mainbranch: true, + }, + } + : repositoryJson, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const cloneUrls = yield* bitbucket.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "pingdotgg/t3code", + }); + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.deepStrictEqual(cloneUrls, { + nameWithOwner: "pingdotgg/t3code", + url: "https://bitbucket.org/pingdotgg/t3code.git", + sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }); + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)); +}); + +it.effect( + "prefers the Bitbucket branching model development branch as the default PR target", + () => { + const { execute, layer } = makeLayer({ + response: (request) => + Response.json( + request.url.endsWith("/branching-model") + ? { + development: { + branch: { name: "develop" }, + name: "develop", + use_mainbranch: false, + }, + } + : repositoryJson, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "develop"); + assert.deepStrictEqual( + execute.mock.calls.map((call) => call[0].url).toSorted(), + [ + "https://api.test.local/2.0/repositories/pingdotgg/t3code", + "https://api.test.local/2.0/repositories/pingdotgg/t3code/branching-model", + ].toSorted(), + ); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "falls back to the repository main branch when the Bitbucket development branch is invalid", + () => { + const { layer } = makeLayer({ + response: (request) => + Response.json( + request.url.endsWith("/branching-model") + ? { + development: { + name: "develop", + use_mainbranch: false, + is_valid: false, + }, + } + : repositoryJson, + ), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "falls back to the repository main branch when the Bitbucket branching model is unavailable", + () => { + const { layer } = makeLayer({ + response: (request) => + request.url.endsWith("/branching-model") + ? Response.json({ error: { message: "Not found" } }, { status: 404 }) + : Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("creates repositories through the Bitbucket REST API", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json(repositoryJson), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const cloneUrls = yield* bitbucket.createRepository({ + cwd: "/repo", + repository: "pingdotgg/t3code", + visibility: "private", + }); + + assert.deepStrictEqual(cloneUrls, { + nameWithOwner: "pingdotgg/t3code", + url: "https://bitbucket.org/pingdotgg/t3code.git", + sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }); + + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); + assert.strictEqual(request?.method, "POST"); + assert.ok(request); + const rawBody = (request.body as { readonly body?: Uint8Array }).body; + assert.ok(rawBody); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { + scm: "git", + is_private: true, + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("creates pull requests using the official REST payload shape", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json(bitbucketPullRequest), + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "bitbucket-pr-body-" }); + yield* fileSystem.writeFileString(bodyFile, "PR body"); + + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile, + }); + + const request = execute.mock.calls[0]?.[0]; + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/pingdotgg/t3code/pullrequests", + ); + assert.strictEqual(request?.method, "POST"); + assert.ok(request); + const rawBody = (request.body as { readonly body?: Uint8Array }).body; + assert.ok(rawBody); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { + title: "Provider PR", + description: "PR body", + source: { + branch: { name: "feature/provider" }, + repository: { full_name: "owner/t3code" }, + }, + destination: { + branch: { name: "main" }, + }, + }); + }).pipe(Effect.provide(layer), Effect.scoped); +}); + +it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { + const { layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const auth = yield* bitbucket.probeAuth; + + assert.deepStrictEqual(auth, { + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { + const { git, layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, + }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.checkoutPullRequest({ + cwd: "/repo", + context: { + provider: { + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", + }, + reference: "42", + force: true, + }); + + assert.strictEqual(git.ensureRemote.mock.calls.length, 0); + assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { + cwd: "/repo", + remoteName: "origin", + remoteBranch: "feature/source-control", + localBranch: "feature/source-control", + }); + assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { + cwd: "/repo", + branch: "feature/source-control", + remoteName: "origin", + remoteBranch: "feature/source-control", + }); + assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { + cwd: "/repo", + refName: "feature/source-control", + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("checks out fork pull requests through an ensured fork remote", () => { + const { git, layer } = makeLayer({ + response: (request) => { + if (request.url.endsWith("/repositories/octocat/t3code")) { + return Response.json({ + ...repositoryJson, + full_name: "octocat/t3code", + links: { + html: { href: "https://bitbucket.org/octocat/t3code" }, + clone: [ + { name: "https", href: "https://bitbucket.org/octocat/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" }, + ], + }, + }); + } + return Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "main" }, + repository: { + full_name: "octocat/t3code", + workspace: { slug: "octocat" }, + }, + }, + }); + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + force: true, + }); + + assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], { + cwd: "/repo", + preferredName: "octocat", + url: "git@bitbucket.org:octocat/t3code.git", + }); + assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { + cwd: "/repo", + remoteName: "octocat", + remoteBranch: "main", + localBranch: "t3code/pr-42/main", + }); + assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { + cwd: "/repo", + branch: "t3code/pr-42/main", + remoteName: "octocat", + remoteBranch: "main", + }); + assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { + cwd: "/repo", + refName: "t3code/pr-42/main", + }); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts new file mode 100644 index 00000000000..748113ba04f --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -0,0 +1,769 @@ +import * as Config from "effect/Config"; +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 Schema from "effect/Schema"; +import { + TrimmedNonEmptyString, + type SourceControlProviderAuth, + type SourceControlRepositoryCloneUrls, + type SourceControlRepositoryVisibility, +} from "@t3tools/contracts"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { sanitizeBranchFragment } from "@t3tools/shared/git"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; + +const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; + +const BitbucketApiEnvConfig = Config.all({ + baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( + Config.withDefault(DEFAULT_API_BASE_URL), + ), + accessToken: Config.string("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), + email: Config.string("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), + apiToken: Config.string("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), +}); + +export class BitbucketApiError extends Schema.TaggedErrorClass()( + "BitbucketApiError", + { + operation: Schema.String, + detail: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} +const isBitbucketApiErrorValue = Schema.is(BitbucketApiError); + +const RawBitbucketRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + links: Schema.Struct({ + html: Schema.optional( + Schema.Struct({ + href: TrimmedNonEmptyString, + }), + ), + clone: Schema.optional( + Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + href: TrimmedNonEmptyString, + }), + ), + ), + }), + mainbranch: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + ), +}); + +const RawBitbucketBranchingModelSchema = Schema.Struct({ + development: Schema.optional( + Schema.Struct({ + branch: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(TrimmedNonEmptyString), + }), + ), + ), + is_valid: Schema.optional(Schema.Boolean), + name: Schema.optional(Schema.NullOr(Schema.String)), + use_mainbranch: Schema.optional(Schema.Boolean), + }), + ), +}); + +const BitbucketUserSchema = Schema.Struct({ + username: Schema.optional(TrimmedNonEmptyString), + display_name: Schema.optional(TrimmedNonEmptyString), + account_id: Schema.optional(TrimmedNonEmptyString), +}); + +export interface BitbucketRepositoryLocator { + readonly workspace: string; + readonly repoSlug: string; +} + +export interface BitbucketApiShape { + readonly probeAuth: Effect.Effect; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect< + ReadonlyArray, + BitbucketApiError + >; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect< + BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, + BitbucketApiError + >; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class BitbucketApi extends Context.Service()( + "t3/source-control/BitbucketApi", +) {} + +function nonEmpty(value: string | undefined): Option.Option { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( + trimmed, + ); + return urlMatch?.[1] ?? trimmed; +} + +function sourceWorkspace(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string | undefined { + if (input.source?.owner) return input.source.owner; + return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; +} + +function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { + switch (state) { + case "open": + return ["OPEN"]; + case "closed": + return ["DECLINED", "SUPERSEDED"]; + case "merged": + return ["MERGED"]; + case "all": + return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; + } +} + +function bitbucketQueryString(filters: ReadonlyArray): string { + return filters.join(" AND "); +} + +function bitbucketStateFilter(states: ReadonlyArray): string { + return states.length === 1 + ? `state = "${states[0]}"` + : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; +} + +function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { + const normalized = value.trim().replace(/\.git$/u, ""); + const parts = normalized.split("/").filter((part) => part.length > 0); + if (parts.length < 2) return null; + const workspace = parts.at(-2); + const repoSlug = parts.at(-1); + return workspace && repoSlug ? { workspace, repoSlug } : null; +} + +function requireRepositoryLocator( + operation: string, + repository: string, +): Effect.Effect { + const locator = parseBitbucketRepositorySlug(repository); + return locator + ? Effect.succeed(locator) + : Effect.fail( + new BitbucketApiError({ + operation, + detail: "Bitbucket repositories must be specified as workspace/repository.", + }), + ); +} + +function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { + const trimmed = remoteUrl.trim(); + if (trimmed.startsWith("git@")) { + const pathStart = trimmed.indexOf(":"); + return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1)); + } + + try { + return parseBitbucketRepositorySlug(new URL(trimmed).pathname); + } catch { + return null; + } +} + +function normalizeRepositoryCloneUrls( + raw: typeof RawBitbucketRepositorySchema.Type, +): SourceControlRepositoryCloneUrls { + const httpClone = + raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? + raw.links.html?.href; + const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; + + return { + nameWithOwner: raw.full_name, + url: httpClone ?? raw.links.html?.href ?? raw.full_name, + sshUrl: sshClone ?? httpClone ?? raw.full_name, + }; +} + +function defaultChangeRequestTargetBranch(input: { + readonly repository: typeof RawBitbucketRepositorySchema.Type; + readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null; +}): string | null { + const repositoryMainBranch = input.repository.mainbranch?.name ?? null; + const development = input.branchingModel?.development; + if (!development || development.use_mainbranch === true || development.is_valid === false) { + return repositoryMainBranch; + } + + const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? ""; + if (developmentBranch.length === 0 || developmentBranch === "null") { + return repositoryMainBranch; + } + + return developmentBranch; +} + +function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { + const trimmed = originRemoteUrl?.trim() ?? ""; + return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); +} + +function selectCloneUrl(input: { + readonly cloneUrls: SourceControlRepositoryCloneUrls; + readonly originRemoteUrl: string | null; +}): string { + return shouldPreferSshRemote(input.originRemoteUrl) + ? input.cloneUrls.sshUrl + : input.cloneUrls.url; +} + +function checkoutBranchName(input: { + readonly pullRequestId: number; + readonly headBranch: string; + readonly isCrossRepository: boolean; +}): string { + if (!input.isCrossRepository) { + return input.headBranch; + } + + return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; +} + +function repositoryNameWithOwner( + repository: Schema.Schema.Type< + typeof BitbucketPullRequests.BitbucketPullRequestSchema + >["source"]["repository"], +): string | null { + const fullName = repository?.full_name?.trim() ?? ""; + return fullName.length > 0 ? fullName : null; +} + +function repositoryOwnerName(repositoryName: string): string { + return repositoryName.split("/")[0]?.trim() || "bitbucket"; +} + +function authFromConfig( + config: Config.Success, +): SourceControlProviderAuth { + if (Option.isSome(config.accessToken)) { + return { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("Bitbucket access token is configured."), + }; + } + + if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + return { + status: "unknown", + account: config.email, + host: Option.some("bitbucket.org"), + detail: Option.some("Bitbucket API token is configured."), + }; + } + + return { + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + ), + }; +} + +function requestError(operation: string, cause: unknown): BitbucketApiError { + return new BitbucketApiError({ + operation, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); +} + +function isBitbucketApiError(cause: unknown): cause is BitbucketApiError { + return isBitbucketApiErrorValue(cause); +} + +function responseError( + operation: string, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect { + return response.text.pipe( + Effect.catch(() => Effect.succeed("")), + Effect.flatMap((body) => + Effect.fail( + new BitbucketApiError({ + operation, + status: response.status, + detail: + body.trim().length > 0 + ? `Bitbucket returned HTTP ${response.status}: ${body.trim()}` + : `Bitbucket returned HTTP ${response.status}.`, + }), + ), + ), + ); +} + +export const make = Effect.fn("makeBitbucketApi")(function* () { + const config = yield* BitbucketApiEnvConfig; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + + const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => { + if (Option.isSome(config.accessToken)) { + return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); + } + if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); + } + return request; + }; + + const decodeResponse = ( + operation: string, + schema: S, + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect => + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation, + detail: "Bitbucket returned invalid JSON for the requested resource.", + cause, + }), + ), + ), + orElse: (failed) => responseError(operation, failed), + })(response); + + const executeJson = ( + operation: string, + request: HttpClientRequest.HttpClientRequest, + schema: S, + ): Effect.Effect => + httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + Effect.mapError((cause) => requestError(operation, cause)), + Effect.flatMap((response) => decodeResponse(operation, schema, response)), + ); + + const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }) { + const fromRepository = + input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null; + if (fromRepository) return fromRepository; + + const fromContext = + input.context?.provider.kind === "bitbucket" + ? parseBitbucketRemoteUrl(input.context.remoteUrl) + : null; + if (fromContext) return fromContext; + + const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to resolve VCS repository for ${input.cwd}.`, + cause, + }), + ), + ); + const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "resolveRepository", + detail: `Failed to list remotes for ${input.cwd}.`, + cause, + }), + ), + ); + + for (const remote of remotes.remotes) { + if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; + const parsed = parseBitbucketRemoteUrl(remote.url); + if (parsed) return parsed; + } + + return yield* new BitbucketApiError({ + operation: "resolveRepository", + detail: `No Bitbucket repository remote was detected for ${input.cwd}.`, + }); + }); + + const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) => + executeJson( + "getRepository", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ), + RawBitbucketRepositorySchema, + ); + + const getRepository = (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator)); + + const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) => + executeJson( + "getBranchingModel", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`, + ), + ), + RawBitbucketBranchingModelSchema, + ); + + const getRawPullRequestFromRepository = ( + repository: BitbucketRepositoryLocator, + reference: string, + ) => + executeJson( + "getPullRequest", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, + ), + ), + BitbucketPullRequests.BitbucketPullRequestSchema, + ); + + const getRawPullRequest = (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), + ); + + const readConfigValueNullable = (cwd: string, key: string) => + git.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + + const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly destinationRepository: BitbucketRepositoryLocator; + readonly sourceRepositoryName: string; + readonly isCrossRepository: boolean; + }) { + if ( + input.context?.provider.kind === "bitbucket" && + !input.isCrossRepository && + parseBitbucketRemoteUrl(input.context.remoteUrl) !== null + ) { + return input.context.remoteName; + } + + if (!input.isCrossRepository) { + const remoteName = yield* git + .resolvePrimaryRemoteName(input.cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (remoteName) return remoteName; + } + + const cloneUrls = yield* getRepository({ + cwd: input.cwd, + repository: input.sourceRepositoryName, + ...(input.context ? { context: input.context } : {}), + }).pipe(Effect.map(normalizeRepositoryCloneUrls)); + const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: input.isCrossRepository + ? repositoryOwnerName(input.sourceRepositoryName) + : input.destinationRepository.workspace, + url: selectCloneUrl({ cloneUrls, originRemoteUrl }), + }); + }); + + return BitbucketApi.of({ + probeAuth: executeJson( + "probeAuth", + HttpClientRequest.get(apiUrl("/user")), + BitbucketUserSchema, + ).pipe( + Effect.map((user) => ({ + status: "authenticated" as const, + account: nonEmpty(user.username ?? user.display_name ?? user.account_id), + host: Option.some("bitbucket.org"), + detail: Option.none(), + })), + Effect.catch(() => Effect.succeed(authFromConfig(config))), + ), + listPullRequests: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => { + const states = toBitbucketStates(input.state); + const query: Record> = { + pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), + sort: "-updated_on", + q: bitbucketQueryString([ + `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, + bitbucketStateFilter(states), + ]), + state: states, + }; + + return executeJson( + "listPullRequests", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + { urlParams: query }, + ), + BitbucketPullRequests.BitbucketPullRequestListSchema, + ); + }), + Effect.map((list) => + list.values.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), + ), + getPullRequest: (input) => + getRawPullRequest(input).pipe( + Effect.map(BitbucketPullRequests.normalizeBitbucketPullRequestRecord), + ), + getRepositoryCloneUrls: (input) => + getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), + createRepository: (input) => + requireRepositoryLocator("createRepository", input.repository).pipe( + Effect.flatMap((repository) => + executeJson( + "createRepository", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ).pipe( + HttpClientRequest.bodyJsonUnsafe({ + scm: "git", + is_private: input.visibility === "private", + }), + ), + RawBitbucketRepositorySchema, + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createPullRequest: (input) => + Effect.gen(function* () { + const repository = yield* resolveRepository(input); + const description = yield* fileSystem.readFileString(input.bodyFile).pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "createPullRequest", + detail: `Failed to read pull request body file ${input.bodyFile}.`, + cause, + }), + ), + ); + const sourceOwner = sourceWorkspace(input); + const body = { + title: input.title, + description, + source: { + branch: { + name: SourceControlProvider.sourceBranch(input), + }, + ...(sourceOwner + ? { + repository: { + full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, + }, + } + : {}), + }, + destination: { + branch: { + name: input.target?.refName ?? input.baseBranch, + }, + }, + }; + + yield* executeJson( + "createPullRequest", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), + BitbucketPullRequests.BitbucketPullRequestSchema, + ); + }), + getDefaultBranch: (input) => + resolveRepository(input).pipe( + Effect.flatMap((locator) => + Effect.all( + { + repository: getRepositoryFromLocator(locator), + branchingModel: getBranchingModelFromLocator(locator).pipe( + Effect.catch(() => + Effect.succeed(null), + ), + ), + }, + { concurrency: "unbounded" }, + ), + ), + Effect.map(defaultChangeRequestTargetBranch), + ), + // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide + // an official checkout CLI. This provider-local path uses GitVcsDriver as a + // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this + // as the source-control provider model: if we support non-Git-compatible + // hosting providers or native JJ/Sapling checkout flows, move this into a + // VCS-specific change-request checkout capability. + checkoutPullRequest: (input) => + Effect.gen(function* () { + const destinationRepository = yield* resolveRepository(input); + const pullRequest = yield* getRawPullRequestFromRepository( + destinationRepository, + input.reference, + ); + const destinationRepositoryName = + repositoryNameWithOwner(pullRequest.destination.repository) ?? + `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; + const sourceRepositoryName = + repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; + const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; + const remoteName = yield* resolveCheckoutRemote({ + cwd: input.cwd, + destinationRepository, + sourceRepositoryName, + isCrossRepository, + ...(input.context ? { context: input.context } : {}), + }); + const remoteBranch = pullRequest.source.branch.name; + const localBranch = checkoutBranchName({ + pullRequestId: pullRequest.id, + headBranch: remoteBranch, + isCrossRepository, + }); + const localBranchNames = yield* git.listLocalBranchNames(input.cwd); + const localBranchExists = localBranchNames.includes(localBranch); + + if (input.force === true || !localBranchExists) { + yield* git.fetchRemoteBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + localBranch, + }); + } else { + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + }); + } + + yield* git.setBranchUpstream({ + cwd: input.cwd, + branch: localBranch, + remoteName, + remoteBranch, + }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + isBitbucketApiError(cause) + ? cause + : new BitbucketApiError({ + operation: "checkoutPullRequest", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ), + }); +}); + +export const layer = Layer.effect(BitbucketApi, make()); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts new file mode 100644 index 00000000000..07a3d386a35 --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -0,0 +1,128 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; + +function makeProvider(bitbucket: Partial) { + return BitbucketSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), + ); +} + +it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "bitbucket", + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("lists Bitbucket PRs through provider-neutral input names", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + }), +); + +it.effect("creates Bitbucket PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it.effect("uses Bitbucket API repository detection for default branch lookup", () => + Effect.gen(function* () { + let cwdInput: string | null = null; + const provider = yield* makeProvider({ + getDefaultBranch: (input) => { + cwdInput = input.cwd; + return Effect.succeed("main"); + }, + }); + + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(cwdInput, "/repo"); + }), +); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts new file mode 100644 index 00000000000..f3fd502f7fb --- /dev/null +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -0,0 +1,128 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketPullRequests from "./bitbucketPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; + +function providerError( + operation: string, + cause: BitbucketApi.BitbucketApiError, +): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "bitbucket", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest( + summary: BitbucketPullRequests.NormalizedBitbucketPullRequestRecord, +): ChangeRequest { + return { + provider: "bitbucket", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "bitbucket", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return bitbucket + .listPullRequests({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + bitbucket.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return bitbucket + .createPullRequest({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + bitbucket + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + bitbucket + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), + getDefaultBranch: (input) => + bitbucket + .getDefaultBranch({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + }) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + bitbucket + .checkoutPullRequest({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + reference: input.reference, + ...(input.force !== undefined ? { force: input.force } : {}), + }) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); + +export const makeDiscovery = Effect.fn("makeBitbucketSourceControlProviderDiscovery")(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + return { + type: "api", + kind: "bitbucket", + label: "Bitbucket", + installHint: + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", + probeAuth: bitbucket.probeAuth, + } satisfies SourceControlProviderDiscovery.SourceControlApiDiscoverySpec; +}); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts new file mode 100644 index 00000000000..fb765b352c2 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -0,0 +1,296 @@ +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubCli from "./GitHubCli.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn(); + +const layer = GitHubCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockRun, + }), + ), +); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("GitHubCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "view", + "#42", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("trims pull request fields decoded from gh json", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 42, + title: " Add PR thread creation \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", + baseRefName: " main ", + headRefName: "\tfeature/pr-threads\t", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: " octocat/codething-mvp ", + }, + headRepositoryOwner: { + login: " octocat ", + }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("skips invalid entries when parsing pr lists", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 43, + title: " Valid PR ", + url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", + baseRefName: " main ", + headRefName: " feature/pr-list ", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/pr-list", + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "main", + headRefName: "feature/pr-list", + state: "open", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/codething-mvp", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates repositories and parses clone URLs from create output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n", + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.createRepository({ + cwd: "/repo", + repository: "octocat/codething-mvp", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: ["repo", "create", "octocat/codething-mvp", "--private"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("falls back to constructed URLs when create output omits a URL", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.createRepository({ + cwd: "/repo", + repository: "octocat/codething-mvp", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("surfaces a friendly error when the pull request is not found", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .getPullRequest({ + cwd: "/repo", + reference: "4888", + }) + .pipe(Effect.flip); + + assert.equal(error.message.includes("Pull request not found"), true); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts new file mode 100644 index 00000000000..14d01aab2ed --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -0,0 +1,375 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; + +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface GitHubCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class GitHubCli extends Context.Service()( + "t3/source-control/GitHubCli", +) {} + +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + +function normalizeGitHubCliError( + operation: "execute" | "stdout", + error: VcsError | unknown, +): GitHubCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: gh") || lower.includes("enoent")) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new GitHubCliError({ + operation, + detail: text, + cause: error, + }); +} + +const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, +}); + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): GitHubRepositoryCloneUrls { + return { + nameWithOwner: raw.nameWithOwner, + url: raw.url, + sshUrl: raw.sshUrl, + }; +} + +/** + * `gh repo create` prints the canonical URL of the new repository on stdout + * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a + * follow-up `gh repo view`, which can race GitHub's GraphQL eventual + * consistency window and falsely report the just-created repo as missing. + */ +function deriveRepositoryCloneUrlsFromCreateOutput( + stdout: string, + repository: string, +): GitHubRepositoryCloneUrls { + const fallbackHost = "github.com"; + const match = stdout.match(/https?:\/\/[^\s]+/); + if (match) { + const cleaned = match[0].replace(/\.git$/, ""); + try { + const parsed = new URL(cleaned); + const pathname = parsed.pathname.replace(/^\/+|\/+$/g, ""); + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 2) { + const nameWithOwner = `${segments[0]}/${segments[1]}`; + return { + nameWithOwner, + url: `${parsed.origin}/${nameWithOwner}`, + sshUrl: `git@${parsed.host}:${nameWithOwner}.git`, + }; + } + } catch { + // Fall through to the input-derived defaults below. + } + } + return { + nameWithOwner: repository, + url: `https://${fallbackHost}/${repository}`, + sshUrl: `git@${fallbackHost}:${repository}.git`, + }; +} + +function decodeGitHubJson( + raw: string, + schema: S, + operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitHubCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +export const make = Effect.fn("makeGitHubCli")(function* () { + const process = yield* VcsProcess.VcsProcess; + + const execute: GitHubCliShape["execute"] = (input) => + process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); + + return GitHubCli.of({ + execute, + listOpenPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "listOpenPullRequests", + detail: `GitHub CLI returned invalid PR list JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), + ); + }), + ), + ), + ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "getPullRequest", + detail: `GitHub CLI returned invalid pull request JSON: ${GitHubPullRequests.formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), + ); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubRepositoryCloneUrlsSchema, + "getRepositoryCloneUrls", + "GitHub CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "create", input.repository, `--${input.visibility}`], + }).pipe( + Effect.map((result) => + deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), + ), + ), + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "create", + "--base", + input.baseBranch, + "--head", + input.headSelector, + "--title", + input.title, + "--body-file", + input.bodyFile, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + }).pipe( + Effect.map((value) => { + const trimmed = value.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitHubCli, make()); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts new file mode 100644 index 00000000000..a480f3bec86 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -0,0 +1,159 @@ +import { assert, 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 { ChildProcessSpawner } from "effect/unstable/process"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; + +const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), + ); +} + +it.effect("maps GitHub PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add GitHub provider", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "github", + number: 42, + title: "Add GitHub provider", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("uses gh json listing for non-open change request state queries", () => + Effect.gen(function* () { + let executeArgs: ReadonlyArray = []; + const provider = yield* makeProvider({ + execute: (input) => { + executeArgs = input.args; + return Effect.succeed( + processResult( + JSON.stringify([ + { + number: 7, + title: "Merged work", + url: "https://github.com/pingdotgg/t3code/pull/7", + baseRefName: "main", + headRefName: "feature/merged", + state: "merged", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ]), + ), + ); + }, + }); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/merged", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(executeArgs, [ + "pr", + "list", + "--head", + "feature/merged", + "--state", + "all", + "--limit", + "10", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ]); + assert.strictEqual(changeRequests[0]?.provider, "github"); + assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.deepStrictEqual( + changeRequests[0]?.updatedAt, + Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + ); + }), +); + +it.effect("treats empty non-open change request listing output as no results", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + execute: () => Effect.succeed(processResult("")), + }); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/empty", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(changeRequests, []); + }), +); + +it.effect("creates GitHub PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts new file mode 100644 index 00000000000..cc892015fce --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -0,0 +1,201 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + SourceControlProviderError, + type ChangeRequest, + type ChangeRequestState, +} from "@t3tools/contracts"; + +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubPullRequests from "./gitHubPullRequests.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +const isSourceControlProviderError = Schema.is(SourceControlProviderError); + +function providerError( + operation: string, + cause: GitHubCli.GitHubCliError, +): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "github", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { + return { + provider: "github", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); + const account = SourceControlProviderDiscovery.matchFirst(output, [ + /Logged in to .* account\s+([^\s(]+)/iu, + /Logged in to .* as\s+([^\s(]+)/iu, + ]); + const host = SourceControlProviderDiscovery.parseCliHost(output); + + if (input.exitCode !== 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "unauthenticated", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "Run `gh auth login` to authenticate GitHub CLI.", + }); + } + + if (account) { + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); + } + + return SourceControlProviderDiscovery.providerAuth({ + status: "unknown", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "GitHub CLI auth status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitHubAuth, + installHint: + "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; + +export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { + const github = yield* GitHubCli.GitHubCli; + + const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] = + (input) => { + if (input.state === "open") { + return github + .listOpenPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + } + + const stateArg: ChangeRequestState | "all" = input.state; + return github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + stateArg, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([]); + } + return Effect.sync(() => GitHubPullRequests.decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed( + decoded.success.map((item) => ({ + ...toChangeRequest(item), + updatedAt: item.updatedAt, + })), + ) + : Effect.fail( + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + detail: "GitHub CLI returned invalid change request JSON.", + cause: decoded.failure, + }), + ), + ), + ); + }), + Effect.mapError((error) => + isSourceControlProviderError(error) + ? error + : providerError("listChangeRequests", error), + ), + ); + }; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "github", + listChangeRequests, + getChangeRequest: (input) => + github.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => + github + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), + getRepositoryCloneUrls: (input) => + github + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + github + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), + getDefaultBranch: (input) => + github + .getDefaultBranch(input) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + github + .checkoutPullRequest(input) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts new file mode 100644 index 00000000000..c075027151a --- /dev/null +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -0,0 +1,339 @@ +import { assert, it, afterEach, expect, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { VcsProcessExitError } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitLabCli from "./GitLabCli.ts"; + +const mockedRun = vi.fn(); +const layer = it.layer( + GitLabCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockedRun, + }), + ), + ), +); + +function processOutput(stdout: string): VcsProcess.VcsProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +afterEach(() => { + mockedRun.mockReset(); +}); + +layer("GitLabCli.layer", (it) => { + it.effect("parses merge request view output", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 42, + title: "Add MR thread creation", + web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + target_branch: "main", + source_branch: "feature/mr-threads", + state: "opened", + source_project_id: 101, + target_project_id: 100, + source_project: { + path_with_namespace: "octocat/t3code", + }, + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getMergeRequest({ + cwd: "/repo", + reference: "42", + }); + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add MR thread creation", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/mr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["mr", "view", "42", "--output", "json"], + }), + ); + }), + ); + + it.effect("skips invalid entries when parsing MR lists", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + iid: 0, + title: "invalid", + web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0", + target_branch: "main", + source_branch: "feature/invalid", + }, + { + iid: 43, + title: " Valid MR ", + web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ", + target_branch: " main ", + source_branch: " feature/mr-list ", + state: "merged", + }, + ]), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.listMergeRequests({ + cwd: "/repo", + headSelector: "feature/mr-list", + state: "all", + }); + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid MR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43", + baseRefName: "main", + headRefName: "feature/mr-list", + state: "merged", + }, + ]); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "mr", + "list", + "--source-branch", + "feature/mr-list", + "--all", + "--per-page", + "20", + "--output", + "json", + ], + }), + ); + }), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + path_with_namespace: "octocat/t3code", + web_url: "https://gitlab.com/octocat/t3code", + http_url_to_repo: "https://gitlab.com/octocat/t3code.git", + ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/t3code", + }); + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/t3code", + url: "https://gitlab.com/octocat/t3code", + sshUrl: "git@gitlab.com:octocat/t3code.git", + }); + }), + ); + + it.effect("creates merge requests through the GitLab API without placing the body in argv", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.createMergeRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider MR", + bodyFile: "/tmp/t3-mr-body.md", + }); + + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "api", + "--method", + "POST", + "projects/:fullpath/merge_requests", + "--raw-field", + "source_branch=feature/provider", + "--raw-field", + "target_branch=main", + "--raw-field", + "title=Provider MR", + "--field", + "description=@/tmp/t3-mr-body.md", + ], + }), + ); + }), + ); + + it.effect("creates repositories under an explicit namespace", () => + Effect.gen(function* () { + mockedRun + + .mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ id: 1234 }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + path_with_namespace: "octocat/t3code", + web_url: "https://gitlab.com/octocat/t3code", + http_url_to_repo: "https://gitlab.com/octocat/t3code.git", + ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", + }), + ), + ), + ); + + const glab = yield* GitLabCli.GitLabCli; + const result = yield* glab.createRepository({ + cwd: "/repo", + repository: "octocat/t3code", + visibility: "public", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/t3code", + url: "https://gitlab.com/octocat/t3code", + sshUrl: "git@gitlab.com:octocat/t3code.git", + }); + expect(mockedRun).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["api", "namespaces/octocat"], + }), + ); + expect(mockedRun).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "api", + "--method", + "POST", + "projects", + "--raw-field", + "path=t3code", + "--raw-field", + "name=t3code", + "--raw-field", + "visibility=public", + "--raw-field", + "namespace_id=1234", + ], + }), + ); + }), + ); + + it.effect("does not pass unsupported force flags when checking out merge requests", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.checkoutMergeRequest({ + cwd: "/repo", + reference: "42", + force: true, + }); + + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["mr", "checkout", "42"], + }), + ); + }), + ); + + it.effect("surfaces a friendly error when the merge request is not found", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab mr view 4888", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + }), + ), + ); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getMergeRequest({ + cwd: "/repo", + reference: "4888", + }); + }).pipe(Effect.flip); + + assert.equal(error.message.includes("Merge request not found"), true); + }), + ); +}); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts new file mode 100644 index 00000000000..faabe87263d --- /dev/null +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -0,0 +1,449 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import type * as DateTime from "effect/DateTime"; + +import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitLabMergeRequests from "./gitLabMergeRequests.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class GitLabCliError extends Schema.TaggedErrorClass()("GitLabCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export interface GitLabMergeRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly updatedAt?: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitLabRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface GitLabCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitLabCliError>; + + readonly getMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createMergeRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class GitLabCli extends Context.Service()( + "t3/source-control/GitLabCli", +) {} + +function isVcsProcessSpawnError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "VcsProcessSpawnError" + ); +} + +function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { + if (error instanceof Error) { + if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { + return new GitLabCliError({ + operation, + detail: "GitLab CLI (`glab`) is required but not available on PATH.", + cause: error, + }); + } + + const lower = error.message.toLowerCase(); + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("glab auth login") || + lower.includes("token") + ) { + return new GitLabCliError({ + operation, + detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("merge request not found") || + lower.includes("not found") || + lower.includes("404") + ) { + return new GitLabCliError({ + operation, + detail: "Merge request not found. Check the MR number or URL and try again.", + cause: error, + }); + } + + return new GitLabCliError({ + operation, + detail: `GitLab CLI command failed: ${error.message}`, + cause: error, + }); + } + + return new GitLabCliError({ + operation, + detail: "GitLab CLI command failed.", + cause: error, + }); +} + +const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ + path_with_namespace: TrimmedNonEmptyString, + web_url: TrimmedNonEmptyString, + http_url_to_repo: TrimmedNonEmptyString, + ssh_url_to_repo: TrimmedNonEmptyString, +}); + +const RawGitLabDefaultBranchSchema = Schema.Struct({ + default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); + +const RawGitLabNamespaceSchema = Schema.Struct({ + id: Schema.Number, +}); + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): GitLabRepositoryCloneUrls { + return { + nameWithOwner: raw.path_with_namespace, + url: raw.web_url, + sshUrl: raw.ssh_url_to_repo, + }; +} + +function decodeGitLabJson( + raw: string, + schema: S, + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitLabCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { + switch (state) { + case "open": + return []; + case "closed": + return ["--closed"]; + case "merged": + return ["--merged"]; + case "all": + return ["--all"]; + } +} + +function normalizeHeadSelector(headSelector: string): string { + const trimmed = headSelector.trim(); + const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed); + return ownerBranch?.[1]?.trim() || trimmed; +} + +function sourceRefName(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeHeadSelector(input.headSelector); +} + +function sourceProjectIdentifier( + source: SourceControlProvider.SourceControlRefSelector | undefined, +): string | null { + return source?.repository ?? source?.owner ?? null; +} + +function toSummaryWithOptionalUpdatedAt( + record: GitLabMergeRequestSummary & { + readonly updatedAt: Option.Option; + }, +): GitLabMergeRequestSummary { + const { updatedAt, ...summary } = record; + return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; +} + +function parseRepositoryPath(repository: string): { + readonly namespacePath: string | null; + readonly projectPath: string; +} { + const parts = repository + .split("/") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + const projectPath = parts.at(-1) ?? repository.trim(); + const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null; + return { namespacePath, projectPath }; +} + +export const make = Effect.fn("makeGitLabCli")(function* () { + const process = yield* VcsProcess.VcsProcess; + + const execute: GitLabCliShape["execute"] = (input) => + process + .run({ + operation: "GitLabCli.execute", + command: "glab", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); + + return GitLabCli.of({ + execute, + listMergeRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "list", + "--source-branch", + sourceRefName(input), + ...stateArgs(input.state), + "--per-page", + String(input.limit ?? 20), + "--output", + "json", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabCliError({ + operation: "listMergeRequests", + detail: `GitLab CLI returned invalid MR list JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)); + }), + ), + ), + ), + getMergeRequest: (input) => + execute({ + cwd: input.cwd, + args: ["mr", "view", input.reference, "--output", "json"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => GitLabMergeRequests.decodeGitLabMergeRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabCliError({ + operation: "getMergeRequest", + detail: `GitLab CLI returned invalid merge request JSON: ${GitLabMergeRequests.formatGitLabJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success)); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["api", `projects/${encodeURIComponent(input.repository)}`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabRepositoryCloneUrlsSchema, + "getRepositoryCloneUrls", + "GitLab CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => { + const { namespacePath, projectPath } = parseRepositoryPath(input.repository); + const namespaceId: Effect.Effect = namespacePath + ? execute({ + cwd: input.cwd, + args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabNamespaceSchema, + "createRepository", + "GitLab CLI returned invalid namespace JSON.", + ), + ), + Effect.map((namespace) => namespace.id), + ) + : Effect.succeed(null); + + return namespaceId.pipe( + Effect.flatMap((resolvedNamespaceId) => + execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "projects", + "--raw-field", + `path=${projectPath}`, + "--raw-field", + `name=${projectPath}`, + "--raw-field", + `visibility=${input.visibility}`, + ...(resolvedNamespaceId === null + ? [] + : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), + ], + }), + ), + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabRepositoryCloneUrlsSchema, + "createRepository", + "GitLab CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, + createMergeRequest: (input) => { + const sourceProject = sourceProjectIdentifier(input.source); + return execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "projects/:fullpath/merge_requests", + "--raw-field", + `source_branch=${sourceRefName(input)}`, + "--raw-field", + `target_branch=${input.target?.refName ?? input.baseBranch}`, + ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []), + "--raw-field", + `title=${input.title}`, + "--field", + `description=@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid); + }, + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["api", "projects/:fullpath"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabDefaultBranchSchema, + "getDefaultBranch", + "GitLab CLI returned invalid repository JSON.", + ), + ), + Effect.map((value) => value.default_branch ?? null), + ), + checkoutMergeRequest: (input) => + execute({ + cwd: input.cwd, + args: ["mr", "checkout", input.reference], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitLabCli, make()); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts new file mode 100644 index 00000000000..94d363d1c1b --- /dev/null +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -0,0 +1,109 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; + +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make().pipe( + Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), + ); +} + +it.effect("maps GitLab MR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getMergeRequest: () => + Effect.succeed({ + number: 42, + title: "Add GitLab provider", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "gitlab", + number: 42, + title: "Add GitLab provider", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("lists GitLab MRs through provider-neutral input names", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listMergeRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + }), +); + +it.effect("creates GitLab MRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createMergeRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider MR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, + title: "Provider MR", + bodyFile: "/tmp/body.md", + }); + }), +); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts new file mode 100644 index 00000000000..ccab2bd1f76 --- /dev/null +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -0,0 +1,146 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; + +function providerError( + operation: string, + cause: GitLabCli.GitLabCliError, +): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "gitlab", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { + return { + provider: "gitlab", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { + const output = SourceControlProviderDiscovery.combinedAuthOutput(input); + const account = SourceControlProviderDiscovery.matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in to .* account\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + ]); + const host = SourceControlProviderDiscovery.parseCliHost(output); + + if (input.exitCode !== 0) { + return SourceControlProviderDiscovery.providerAuth({ + status: "unauthenticated", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "Run `glab auth login` to authenticate GitLab CLI.", + }); + } + + if (account) { + return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); + } + + return SourceControlProviderDiscovery.providerAuth({ + status: "unknown", + host, + detail: + SourceControlProviderDiscovery.firstSafeAuthLine(output) ?? + "GitLab CLI auth status could not be parsed.", + }); +} + +export const discovery = { + type: "cli", + kind: "gitlab", + label: "GitLab", + executable: "glab", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitLabAuth, + installHint: + "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", +} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec; + +export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitlab", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return gitlab + .listMergeRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + }, + getChangeRequest: (input) => + gitlab.getMergeRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return gitlab + .createMergeRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); + }, + getRepositoryCloneUrls: (input) => + gitlab + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + gitlab + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), + getDefaultBranch: (input) => + gitlab + .getDefaultBranch(input) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + gitlab + .checkoutMergeRequest(input) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts new file mode 100644 index 00000000000..d1c6c65c752 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -0,0 +1,259 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessSpawnError } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; + +const sourceControlProviderRegistryTestLayer = (input: { + readonly bitbucket: Partial; + readonly process: Partial; +}) => + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), + Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), + Layer.mock(VcsProcess.VcsProcess)(input.process), + ), + ), + ); + +const processOutput = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): VcsProcess.VcsProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), + stdout, + stderr: options?.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +it.effect("reports implemented tools separately from locally available executables", () => { + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.command === "git") { + return Effect.succeed(processOutput("git version 2.51.0\n")); + } + if (input.command === "gh" && input.args[0] === "--version") { + return Effect.succeed(processOutput("gh version 2.83.0\n")); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com +Logged in to github.com account juliusmarminge (keyring) +- Active account: true +- Git operations protocol: ssh +- Token: gho_************************************ +- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' +`), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + ), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + assert.deepStrictEqual( + result.versionControlSystems.map((item) => ({ + kind: item.kind, + implemented: item.implemented, + status: item.status, + })), + [ + { kind: "git", implemented: true, status: "available" }, + { kind: "jj", implemented: false, status: "missing" }, + ], + ); + assert.deepStrictEqual( + result.sourceControlProviders.map((item) => ({ + kind: item.kind, + status: item.status, + auth: item.auth.status, + account: item.auth.account, + })), + [ + { + kind: "github", + status: "available", + auth: "authenticated", + account: Option.some("juliusmarminge"), + }, + { + kind: "gitlab", + status: "missing", + auth: "unknown", + account: Option.none(), + }, + { + kind: "azure-devops", + status: "missing", + auth: "unknown", + account: Option.none(), + }, + { + kind: "bitbucket", + status: "available", + auth: "unauthenticated", + account: Option.none(), + }, + ], + ); + const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); + assert.ok(bitbucket); + assert.strictEqual(bitbucket.executable, undefined); + }).pipe(Effect.provide(testLayer)); +}); + +it.effect("probes provider authentication without exposing token details", () => { + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version test\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com +Logged in to github.com account octocat (keyring) +- Token: gho_************************************ +- Token scopes: 'repo' +`), + ); + } + if (input.command === "glab" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`gitlab.com +Logged in to gitlab.com as gitlab-user +`), + ); + } + if ( + input.command === "az" && + input.args.join(" ") === "account show --query user.name -o tsv" + ) { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "authenticated", + account: Option.some("bitbucket-user"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + assert.deepStrictEqual( + result.sourceControlProviders.map((item) => ({ + kind: item.kind, + auth: item.auth.status, + account: item.auth.account, + detail: item.auth.detail, + })), + [ + { + kind: "github", + auth: "authenticated", + account: Option.some("octocat"), + detail: Option.none(), + }, + { + kind: "gitlab", + auth: "authenticated", + account: Option.some("gitlab-user"), + detail: Option.none(), + }, + { + kind: "azure-devops", + auth: "authenticated", + account: Option.some("azure-user@example.com"), + detail: Option.none(), + }, + { + kind: "bitbucket", + auth: "authenticated", + account: Option.some("bitbucket-user"), + detail: Option.none(), + }, + ], + ); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts new file mode 100644 index 00000000000..6b00cf779fa --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -0,0 +1,147 @@ +import { + type SourceControlDiscoveryResult, + type VcsDiscoveryItem, + type VcsDriverKind, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; + +interface DiscoveryProbe { + readonly label: string; + readonly executable?: string; + readonly versionArgs?: ReadonlyArray; + readonly implemented: boolean; + readonly installHint: string; +} + +type VcsProbe = DiscoveryProbe & { + readonly kind: VcsDriverKind; + readonly executable: string; + readonly versionArgs: ReadonlyArray; +}; + +interface DiscoveryProbeResult { + readonly kind: Kind; + readonly label: string; + readonly executable?: string; + readonly implemented: boolean; + readonly status: "available" | "missing"; + readonly version: Option.Option; + readonly installHint: string; + readonly detail: Option.Option; +} + +const VCS_PROBES: ReadonlyArray = [ + { + kind: "git", + label: "Git", + executable: "git", + versionArgs: ["--version"], + implemented: true, + installHint: "Install Git from https://git-scm.com/downloads or with your package manager.", + }, + { + kind: "jj", + label: "Jujutsu", + executable: "jj", + versionArgs: ["--version"], + implemented: false, + installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", + }, +]; + +export interface SourceControlDiscoveryShape { + readonly discover: Effect.Effect; +} + +export class SourceControlDiscovery extends Context.Service< + SourceControlDiscovery, + SourceControlDiscoveryShape +>()("t3/source-control/SourceControlDiscovery") {} + +export const layer = Layer.effect( + SourceControlDiscovery, + Effect.gen(function* () { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + const sourceControlProviders = + yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + + const probe = ( + input: DiscoveryProbe & { readonly kind: Kind }, + ): Effect.Effect> => { + const executable = input.executable; + const versionArgs = input.versionArgs; + + if (!executable || !versionArgs) { + return Effect.succeed({ + kind: input.kind, + label: input.label, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: Option.some(input.installHint), + } satisfies DiscoveryProbeResult); + } + + return process + .run({ + operation: "source-control.discovery.probe", + command: executable, + args: versionArgs, + cwd: config.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.kind, + label: input.label, + executable, + implemented: input.implemented, + status: "available" as const, + version: Option.orElse( + SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), + () => SourceControlProviderDiscovery.firstNonEmptyLine(result.stderr), + ), + installHint: input.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.kind, + label: input.label, + executable, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: SourceControlProviderDiscovery.detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); + }; + + return SourceControlDiscovery.of({ + discover: Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ), + sourceControlProviders: sourceControlProviders.discover, + }), + }); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts new file mode 100644 index 00000000000..a0465008212 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -0,0 +1,102 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import type { + ChangeRequest, + ChangeRequestState, + SourceControlProviderError, + SourceControlProviderInfo, + SourceControlProviderKind, + SourceControlRepositoryCloneUrls, + SourceControlRepositoryVisibility, +} from "@t3tools/contracts"; + +export interface SourceControlProviderContext { + readonly provider: SourceControlProviderInfo; + readonly remoteName: string; + readonly remoteUrl: string; +} + +export interface SourceControlRefSelector { + readonly refName: string; + readonly owner?: string; + readonly repository?: string; +} + +export function parseSourceControlOwnerRef( + headSelector: string, +): SourceControlRefSelector | undefined { + const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim()); + const owner = match?.[1]?.trim(); + const refName = match?.[2]?.trim(); + return owner && refName ? { owner, refName } : undefined; +} + +export function normalizeSourceBranch(headSelector: string): string { + return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); +} + +export function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +export function sourceControlRefFromInput(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): SourceControlRefSelector | undefined { + return input.source ?? parseSourceControlOwnerRef(input.headSelector); +} + +export interface SourceControlProviderShape { + readonly kind: SourceControlProviderKind; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class SourceControlProvider extends Context.Service< + SourceControlProvider, + SourceControlProviderShape +>()("t3/source-control/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts new file mode 100644 index 00000000000..23bbada043a --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -0,0 +1,238 @@ +import type { + SourceControlProviderAuth, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type * as VcsProcess from "../vcs/VcsProcess.ts"; + +export interface SourceControlAuthProbeInput { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; +} + +interface SourceControlDiscoverySpecBase { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly installHint: string; +} + +export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "cli"; + readonly executable: string; + readonly versionArgs: ReadonlyArray; + readonly authArgs: ReadonlyArray; + readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; +}; + +export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "api"; + readonly probeAuth: Effect.Effect; +}; + +export type SourceControlProviderDiscoverySpec = + | SourceControlCliDiscoverySpec + | SourceControlApiDiscoverySpec; + +interface DiscoveryProbeResult { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly executable: string; + readonly status: "available" | "missing"; + readonly version: Option.Option; + readonly installHint: string; + readonly detail: Option.Option; +} + +export function firstNonEmptyLine(text: string): Option.Option { + const line = text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + return line === undefined ? Option.none() : Option.some(line); +} + +export function detailFromCause(cause: unknown): Option.Option { + if (cause instanceof Error && cause.message.trim().length > 0) { + return Option.some(cause.message.trim()); + } + return Option.none(); +} + +function authAccount(account: string | undefined): Option.Option { + const trimmed = account?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authHost(host: string | undefined): Option.Option { + const trimmed = host?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authDetail(detail: string | undefined): Option.Option { + const trimmed = detail?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +export function providerAuth(input: { + readonly status: SourceControlProviderAuth["status"]; + readonly account?: string | undefined; + readonly host?: string | undefined; + readonly detail?: string | undefined; +}): SourceControlProviderAuth { + return { + status: input.status, + account: authAccount(input.account), + host: authHost(input.host), + detail: authDetail(input.detail), + }; +} + +export function unknownAuth(detail?: string): SourceControlProviderAuth { + return providerAuth({ status: "unknown", detail }); +} + +export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { + return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); +} + +function sanitizedAuthLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); +} + +export function firstSafeAuthLine(text: string): string | undefined { + return sanitizedAuthLines(text)[0]; +} + +export function parseCliHost(text: string): string | undefined { + return sanitizedAuthLines(text) + .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) + .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); +} + +export function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { + for (const pattern of patterns) { + const match = pattern.exec(text); + const value = match?.[1]?.trim(); + if (value && value.length > 0) return value; + } + return undefined; +} + +function probeCli(input: { + readonly spec: SourceControlCliDiscoverySpec; + readonly process: VcsProcess.VcsProcessShape; + readonly cwd: string; +}): Effect.Effect { + return input.process + .run({ + operation: "source-control.discovery.probe", + command: input.spec.executable, + args: input.spec.versionArgs, + cwd: input.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.spec.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "missing" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); +} + +export function probeSourceControlProvider(input: { + readonly spec: SourceControlProviderDiscoverySpec; + readonly process: VcsProcess.VcsProcessShape; + readonly cwd: string; +}): Effect.Effect { + if (input.spec.type === "api") { + return input.spec.probeAuth.pipe( + Effect.map( + (auth) => + ({ + kind: input.spec.kind, + label: input.spec.label, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + }) satisfies SourceControlProviderDiscoveryItem, + ), + ); + } + + const spec = input.spec; + + return probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }).pipe( + Effect.flatMap((item) => { + if (item.status !== "available") { + return Effect.succeed({ + ...item, + auth: unknownAuth("Hosting integration command was not found on the server PATH."), + } satisfies SourceControlProviderDiscoveryItem); + } + + return input.process + .run({ + operation: "source-control.discovery.auth", + command: spec.executable, + args: spec.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ + ...item, + auth: spec.parseAuth(result), + }) satisfies SourceControlProviderDiscoveryItem, + ), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); + }), + ); +} diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts new file mode 100644 index 00000000000..829f6be1eb9 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -0,0 +1,148 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 { ServerConfig } from "../config.ts"; +import type * as VcsDriver from "../vcs/VcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitHubCli from "./GitHubCli.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; + +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +function makeRegistry(input: { + readonly remotes: ReadonlyArray<{ + readonly name: string; + readonly url: string; + }>; +}) { + const driver = { + listRemotes: () => + Effect.succeed({ + remotes: input.remotes.map((remote) => ({ + ...remote, + pushUrl: Option.none(), + isPrimary: remote.name === "origin", + })), + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + } satisfies Partial; + + const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriverShape), + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriver.VcsDriverShape, + }), + }); + + return SourceControlProviderRegistry.make().pipe( + Effect.provide( + Layer.mergeAll( + registryLayer, + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(VcsProcess.VcsProcess)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), + ); +} + +it.effect("routes GitHub remotes to the GitHub provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "github"); + }), +); + +it.effect("routes directly by provider kind for remote-first workflows", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + }); + + const provider = yield* registry.get("github"); + + assert.strictEqual(provider.kind, "github"); + }), +); + +it.effect("routes GitLab remotes to the GitLab provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "gitlab"); + }), +); + +it.effect("routes Bitbucket remotes to the Bitbucket provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "bitbucket"); + }), +); + +it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "azure-devops"); + }), +); + +it.effect("falls back to a non-origin remote when origin is not configured", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "azure-devops"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts new file mode 100644 index 00000000000..28826e764b0 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -0,0 +1,256 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { + SourceControlProviderError, + type SourceControlProviderDiscoveryItem, +} from "@t3tools/contracts"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { ServerConfig } from "../config.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; +const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); + +export interface SourceControlProviderRegistration { + readonly kind: SourceControlProviderKind; + readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly discovery: SourceControlProviderDiscovery.SourceControlProviderDiscoverySpec; +} + +export interface SourceControlProviderHandle { + readonly provider: SourceControlProvider.SourceControlProviderShape; + readonly context: SourceControlProvider.SourceControlProviderContext | null; +} + +export interface SourceControlProviderRegistryShape { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect; + readonly resolveHandle: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly resolve: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly discover: Effect.Effect>; +} + +export class SourceControlProviderRegistry extends Context.Service< + SourceControlProviderRegistry, + SourceControlProviderRegistryShape +>()("t3/source-control/SourceControlProviderRegistry") {} + +function unsupportedProvider( + kind: SourceControlProviderKind, +): SourceControlProvider.SourceControlProviderShape { + const unsupported = (operation: string) => + Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation, + detail: `No ${kind} source control provider is registered.`, + }), + ); + + return SourceControlProvider.SourceControlProvider.of({ + kind, + listChangeRequests: () => unsupported("listChangeRequests"), + getChangeRequest: () => unsupported("getChangeRequest"), + createChangeRequest: () => unsupported("createChangeRequest"), + getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), + createRepository: () => unsupported("createRepository"), + getDefaultBranch: () => unsupported("getDefaultBranch"), + checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), + }); +} + +function providerDetectionError(operation: string, cwd: string, cause: unknown) { + return new SourceControlProviderError({ + provider: "unknown", + operation, + detail: `Failed to detect source control provider for ${cwd}.`, + cause, + }); +} + +function selectProviderContext( + remotes: ReadonlyArray<{ + readonly name: string; + readonly url: string; + }>, +): SourceControlProvider.SourceControlProviderContext | null { + const candidates = remotes + .map((remote) => { + const provider = detectSourceControlProviderFromRemoteUrl(remote.url); + return provider + ? { + provider, + remoteName: remote.name, + remoteUrl: remote.url, + } + : null; + }) + .filter((value): value is SourceControlProvider.SourceControlProviderContext => value !== null); + + return ( + candidates.find((candidate) => candidate.remoteName === "origin") ?? + candidates.find((candidate) => candidate.provider.kind !== "unknown") ?? + candidates[0] ?? + null + ); +} + +function bindProviderContext( + provider: SourceControlProvider.SourceControlProviderShape, + context: SourceControlProvider.SourceControlProviderContext | null, +): SourceControlProvider.SourceControlProviderShape { + if (context === null) { + return provider; + } + + return SourceControlProvider.SourceControlProvider.of({ + kind: provider.kind, + listChangeRequests: (input) => + provider.listChangeRequests({ + ...input, + context: input.context ?? context, + }), + getChangeRequest: (input) => + provider.getChangeRequest({ + ...input, + context: input.context ?? context, + }), + createChangeRequest: (input) => + provider.createChangeRequest({ + ...input, + context: input.context ?? context, + }), + getRepositoryCloneUrls: (input) => + provider.getRepositoryCloneUrls({ + ...input, + context: input.context ?? context, + }), + createRepository: (input) => provider.createRepository(input), + getDefaultBranch: (input) => + provider.getDefaultBranch({ + ...input, + context: input.context ?? context, + }), + checkoutChangeRequest: (input) => + provider.checkoutChangeRequest({ + ...input, + context: input.context ?? context, + }), + }); +} + +export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( + function* (registrations: ReadonlyArray) { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const providers = new Map< + SourceControlProviderKind, + SourceControlProvider.SourceControlProviderShape + >(registrations.map((registration) => [registration.kind, registration.provider])); + const discoverySpecs = registrations.map((registration) => registration.discovery); + + const get: SourceControlProviderRegistryShape["get"] = (kind) => + Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); + + const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( + function* (cwd: string) { + const handle = yield* vcsRegistry + .resolve({ cwd }) + .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + const remotes = yield* handle.driver + .listRemotes(cwd) + .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + + return selectProviderContext(remotes.remotes); + }, + ); + + const providerContextCache = yield* Cache.makeWith< + string, + SourceControlProvider.SourceControlProviderContext | null, + SourceControlProviderError + >(detectProviderContext, { + capacity: PROVIDER_DETECTION_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), + }); + + const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => + Cache.get(providerContextCache, input.cwd).pipe( + Effect.map((context) => { + const kind = context?.provider.kind ?? "unknown"; + const provider = providers.get(kind) ?? unsupportedProvider(kind); + return { + provider: bindProviderContext(provider, context), + context, + } satisfies SourceControlProviderHandle; + }), + ); + + return SourceControlProviderRegistry.of({ + get, + resolveHandle, + resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + discover: Effect.all( + discoverySpecs.map((spec) => + SourceControlProviderDiscovery.probeSourceControlProvider({ + spec, + process, + cwd: config.cwd, + }), + ), + { concurrency: "unbounded" }, + ), + }); + }, +); + +export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { + const github = yield* GitHubSourceControlProvider.make(); + const gitlab = yield* GitLabSourceControlProvider.make(); + const bitbucket = yield* BitbucketSourceControlProvider.make(); + const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery(); + const azureDevOps = yield* AzureDevOpsSourceControlProvider.make(); + return yield* makeWithProviders([ + { + kind: "github", + provider: github, + discovery: GitHubSourceControlProvider.discovery, + }, + { + kind: "gitlab", + provider: gitlab, + discovery: GitLabSourceControlProvider.discovery, + }, + { + kind: "azure-devops", + provider: azureDevOps, + discovery: AzureDevOpsSourceControlProvider.discovery, + }, + { + kind: "bitbucket", + provider: bitbucket, + discovery: bitbucketDiscovery, + }, + ]); +}); + +export const layer = Layer.effect(SourceControlProviderRegistry, make()); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts new file mode 100644 index 00000000000..811b55c70a3 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -0,0 +1,320 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; + +const CLONE_URLS = { + nameWithOwner: "octocat/t3code", + url: "https://github.com/octocat/t3code", + sshUrl: "git@github.com:octocat/t3code.git", +}; + +function makeProvider( + overrides: Partial = {}, +): SourceControlProvider.SourceControlProviderShape { + const unsupported = (operation: string) => + Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< + never, + SourceControlProviderError + >; + + return { + kind: "github", + listChangeRequests: () => unsupported("listChangeRequests"), + getChangeRequest: () => unsupported("getChangeRequest"), + createChangeRequest: () => unsupported("createChangeRequest"), + getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), + createRepository: () => Effect.succeed(CLONE_URLS), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), + ...overrides, + }; +} + +function processOutput(): GitVcsDriver.ExecuteGitResult { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +function makeLayer(input: { + readonly provider?: SourceControlProvider.SourceControlProviderShape; + readonly git?: Partial; +}) { + return SourceControlRepositoryService.layer.pipe( + Layer.provide( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + get: () => Effect.succeed(input.provider ?? makeProvider()), + }), + ), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: () => Effect.succeed(processOutput()), + ensureRemote: () => Effect.succeed("origin"), + pushCurrentBranch: () => + Effect.succeed({ + status: "pushed" as const, + branch: "feature/remote-v1", + upstreamBranch: "origin/feature/remote-v1", + setUpstream: true, + }), + ...input.git, + }), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })), + Layer.provideMerge(NodeServices.layer), + ); +} + +it.effect("looks up repositories through the requested provider without search", () => { + const calls: Array<{ cwd: string; repository: string }> = []; + const provider = makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.sync(() => { + calls.push({ cwd: input.cwd, repository: input.repository }); + return CLONE_URLS; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.lookupRepository({ + provider: "github", + repository: "octocat/t3code", + cwd: "/workspace", + }); + + assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS }); + assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + +it.effect("clones a looked-up repository into the requested destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-parent-", + }); + const destinationPath = `${parent}/t3code`; + const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + destinationPath, + protocol: "https", + }); + + assert.deepStrictEqual(result, { + cwd: destinationPath, + remoteUrl: CLONE_URLS.url, + repository: { provider: "github", ...CLONE_URLS }, + }); + assert.deepStrictEqual(cloneCalls, [ + { + cwd: parent, + args: ["clone", CLONE_URLS.url, "t3code"], + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + Effect.sync(() => { + cloneCalls.push({ cwd: input.cwd, args: input.args }); + return processOutput(); + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { + const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; + const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; + const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; + const provider = makeProvider({ + createRepository: (input) => + Effect.sync(() => { + createCalls.push({ + cwd: input.cwd, + repository: input.repository, + visibility: input.visibility, + }); + return CLONE_URLS; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.publishRepository({ + cwd: "/workspace", + provider: "github", + repository: "octocat/t3code", + visibility: "private", + remoteName: "origin", + protocol: "ssh", + }); + + assert.deepStrictEqual(result, { + repository: { provider: "github", ...CLONE_URLS }, + remoteName: "origin", + remoteUrl: CLONE_URLS.sshUrl, + branch: "feature/remote-v1", + upstreamBranch: "origin/feature/remote-v1", + status: "pushed", + }); + assert.deepStrictEqual(createCalls, [ + { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" }, + ]); + assert.deepStrictEqual(remoteCalls, [ + { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, + ]); + assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]); + }).pipe( + Effect.provide( + makeLayer({ + provider, + git: { + ensureRemote: (input) => + Effect.sync(() => { + remoteCalls.push(input); + return "origin"; + }), + pushCurrentBranch: (cwd, _fallbackBranch, options) => + Effect.sync(() => { + pushCalls.push({ cwd, remoteName: options?.remoteName }); + return { + status: "pushed" as const, + branch: "feature/remote-v1", + upstreamBranch: "origin/feature/remote-v1", + setUpstream: true, + }; + }), + }, + }), + ), + ); +}); + +it.effect("publishes to the remote name returned by ensureRemote", () => { + const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.publishRepository({ + cwd: "/workspace", + provider: "github", + repository: "octocat/t3code", + visibility: "private", + remoteName: "origin", + protocol: "ssh", + }); + + assert.equal(result.remoteName, "origin-1"); + assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]); + }).pipe( + Effect.provide( + makeLayer({ + git: { + ensureRemote: () => Effect.succeed("origin-1"), + pushCurrentBranch: (cwd, _fallbackBranch, options) => + Effect.sync(() => { + pushCalls.push({ cwd, remoteName: options?.remoteName }); + return { + status: "pushed" as const, + branch: "feature/remote-v1", + upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`, + setUpstream: true, + }; + }), + }, + }), + ), + ); +}); + +it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { + let pushCalls = 0; + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.publishRepository({ + cwd: "/workspace", + provider: "github", + repository: "octocat/t3code", + visibility: "private", + remoteName: "origin", + protocol: "ssh", + }); + + assert.deepStrictEqual(result, { + repository: { provider: "github", ...CLONE_URLS }, + remoteName: "origin", + remoteUrl: CLONE_URLS.sshUrl, + branch: "main", + status: "remote_added", + }); + assert.strictEqual(pushCalls, 0); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + input.args[0] === "rev-parse" + ? Effect.fail( + new GitCommandError({ + operation: input.operation, + command: "git rev-parse --verify HEAD", + cwd: input.cwd, + detail: "fatal: Needed a single revision", + }), + ) + : Effect.succeed(processOutput()), + statusDetails: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + }), + pushCurrentBranch: () => + Effect.sync(() => { + pushCalls += 1; + return { + status: "pushed" as const, + branch: "main", + upstreamBranch: "origin/main", + setUpstream: true, + }; + }), + }, + }), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts new file mode 100644 index 00000000000..f1ee1940390 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -0,0 +1,320 @@ +import * as NodeOS from "node:os"; +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + SourceControlRepositoryError, + type SourceControlCloneRepositoryInput, + type SourceControlCloneRepositoryResult, + type SourceControlCloneProtocol, + type SourceControlProviderKind, + type SourceControlPublishRepositoryInput, + type SourceControlPublishRepositoryResult, + type SourceControlRepositoryCloneUrls, + type SourceControlRepositoryInfo, + type SourceControlRepositoryLookupInput, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); + +export interface SourceControlRepositoryServiceShape { + readonly lookupRepository: ( + input: SourceControlRepositoryLookupInput, + ) => Effect.Effect; + readonly cloneRepository: ( + input: SourceControlCloneRepositoryInput, + ) => Effect.Effect; + readonly publishRepository: ( + input: SourceControlPublishRepositoryInput, + ) => Effect.Effect; +} + +export class SourceControlRepositoryService extends Context.Service< + SourceControlRepositoryService, + SourceControlRepositoryServiceShape +>()("t3/source-control/SourceControlRepositoryService") {} + +function detailFromUnknown(cause: unknown): string { + if (typeof cause === "object" && cause !== null) { + if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) { + return cause.detail; + } + if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) { + return cause.message; + } + } + + return "An unexpected source control error occurred."; +} + +function repositoryError(input: { + readonly operation: string; + readonly provider: SourceControlProviderKind; + readonly detail: string; + readonly cause?: unknown; +}): SourceControlRepositoryError { + return new SourceControlRepositoryError({ + provider: input.provider, + operation: input.operation, + detail: input.detail, + ...(input.cause === undefined ? {} : { cause: input.cause }), + }); +} + +function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { + return Effect.mapError((cause: unknown) => + isSourceControlRepositoryError(cause) + ? cause + : repositoryError({ + operation, + provider, + detail: detailFromUnknown(cause), + cause, + }), + ); +} + +function toRepositoryInfo( + provider: SourceControlProviderKind, + urls: SourceControlRepositoryCloneUrls, +): SourceControlRepositoryInfo { + return { + provider, + nameWithOwner: urls.nameWithOwner, + url: urls.url, + sshUrl: urls.sshUrl, + }; +} + +function selectRemoteUrl( + urls: SourceControlRepositoryCloneUrls, + protocol: SourceControlCloneProtocol | undefined, +): string { + switch (protocol ?? "auto") { + case "https": + return urls.url; + case "ssh": + case "auto": + return urls.sshUrl; + } +} + +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return NodeOS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(NodeOS.homedir(), input.slice(2)); + } + return input; +} + +export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const path = yield* Path.Path; + const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + + const ensureConcreteProvider = (input: { + readonly operation: string; + readonly provider: SourceControlProviderKind; + }) => { + if (input.provider !== "unknown") { + return Effect.succeed(input.provider); + } + + return Effect.fail( + repositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a source control provider before continuing.", + }), + ); + }; + + const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* ( + input: SourceControlRepositoryLookupInput, + ) { + const providerKind = yield* ensureConcreteProvider({ + operation: "lookupRepository", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: input.cwd ?? config.cwd, + repository: input.repository.trim(), + }); + return toRepositoryInfo(providerKind, urls); + }); + + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( + function* (destinationPath: string) { + const trimmed = destinationPath.trim(); + if (trimmed.length === 0) { + return yield* repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Choose a destination path before cloning.", + }); + } + + return path.resolve(expandHomePath(trimmed, path)); + }, + ); + + const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( + function* (destinationPath: string) { + const normalizedDestination = yield* normalizeDestinationPath(destinationPath); + if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) { + const entries = yield* fileSystem + .readDirectory(normalizedDestination, { recursive: false }) + .pipe( + Effect.mapError((cause) => + repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not a directory.", + cause, + }), + ), + ); + if (entries.length > 0) { + return yield* repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not empty.", + }); + } + } else { + yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); + } + + return { + destinationPath: normalizedDestination, + parentPath: path.dirname(normalizedDestination), + directoryName: path.basename(normalizedDestination), + }; + }, + ); + + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + input: SourceControlCloneRepositoryInput, + ) { + const preparedDestination = yield* prepareDestination(input.destinationPath); + let repository: SourceControlRepositoryInfo | null = null; + let remoteUrl = input.remoteUrl?.trim() ?? null; + let provider: SourceControlProviderKind = input.provider ?? "unknown"; + + if (input.provider && input.repository) { + repository = yield* lookupRepository({ + provider: input.provider, + repository: input.repository, + cwd: preparedDestination.parentPath, + }); + remoteUrl = selectRemoteUrl(repository, input.protocol); + provider = input.provider; + } + + if (!remoteUrl) { + return yield* repositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }); + } + + yield* git.execute({ + operation: "SourceControlRepositoryService.cloneRepository", + cwd: preparedDestination.parentPath, + args: ["clone", remoteUrl, preparedDestination.directoryName], + timeoutMs: 120_000, + maxOutputBytes: 256 * 1024, + }); + + return { + cwd: preparedDestination.destinationPath, + remoteUrl, + repository, + }; + }); + + const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( + function* (input: SourceControlPublishRepositoryInput) { + const providerKind = yield* ensureConcreteProvider({ + operation: "publishRepository", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + const urls = yield* provider.createRepository({ + cwd: input.cwd, + repository: input.repository.trim(), + visibility: input.visibility, + }); + const remoteUrl = selectRemoteUrl(urls, input.protocol); + const remoteName = yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: input.remoteName?.trim() || "origin", + url: remoteUrl, + }); + + // An empty local repo (no commits) would make `git push HEAD:...` fail + // with an opaque "src refspec HEAD does not match any". Treat this as a + // partial success: the remote was created and wired up, but there is + // nothing to push yet. + const hasCommits = yield* git + .execute({ + operation: "SourceControlRepositoryService.publishRepository.headCheck", + cwd: input.cwd, + args: ["rev-parse", "--verify", "HEAD"], + }) + .pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + if (!hasCommits) { + const details = yield* git + .statusDetails(input.cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + return { + repository: toRepositoryInfo(providerKind, urls), + remoteName, + remoteUrl, + branch: details?.branch ?? "main", + status: "remote_added" as const, + }; + } + + const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName }); + + return { + repository: toRepositoryInfo(providerKind, urls), + remoteName, + remoteUrl, + branch: pushResult.branch, + ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}), + status: "pushed" as const, + }; + }, + ); + + return SourceControlRepositoryService.of({ + lookupRepository: (input) => + lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), + cloneRepository: (input) => + cloneRepository(input).pipe( + mapRepositoryError("cloneRepository", input.provider ?? "unknown"), + ), + publishRepository: (input) => + publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), + }); +}); + +export const layer = Layer.effect(SourceControlRepositoryService, make()); diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts new file mode 100644 index 00000000000..e8f138e0e32 --- /dev/null +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -0,0 +1,111 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedAzureDevOpsPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; +} + +const AzureDevOpsPullRequestSchema = Schema.Struct({ + pullRequestId: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.optional(Schema.String), + sourceRefName: TrimmedNonEmptyString, + targetRefName: TrimmedNonEmptyString, + status: Schema.String, + creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + _links: Schema.optional( + Schema.Struct({ + web: Schema.optional( + Schema.Struct({ + href: Schema.String, + }), + ), + }), + ), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRefName(refName: string): string { + return refName.trim().replace(/^refs\/heads\//, ""); +} + +function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" { + switch (status.trim().toLowerCase()) { + case "completed": + return "merged"; + case "abandoned": + return "closed"; + default: + return "open"; + } +} + +function normalizeAzureDevOpsPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedAzureDevOpsPullRequestRecord { + return { + number: raw.pullRequestId, + title: raw.title, + url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "", + baseRefName: normalizeRefName(raw.targetRefName), + headRefName: normalizeRefName(raw.sourceRefName), + state: normalizeAzureDevOpsPullRequestState(raw.status), + updatedAt: (raw.closedDate ?? Option.none()).pipe( + Option.orElse(() => raw.creationDate ?? Option.none()), + ), + }; +} + +const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); +const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); + +export const formatAzureDevOpsJsonDecodeError = formatSchemaError; + +export function decodeAzureDevOpsPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeAzureDevOpsPullRequestList(raw); + if (Result.isSuccess(result)) { + const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); + } + return Result.fail(result.failure); +} + +export function decodeAzureDevOpsPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeAzureDevOpsPullRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts new file mode 100644 index 00000000000..6d67477bca7 --- /dev/null +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -0,0 +1,106 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; + +export interface NormalizedBitbucketPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export const BitbucketRepositoryRefSchema = Schema.Struct({ + full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspace: Schema.optional( + Schema.NullOr( + Schema.Struct({ + slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + }), + ), + ), +}); + +export const BitbucketPullRequestBranchSchema = Schema.Struct({ + repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), + branch: Schema.Struct({ + name: TrimmedNonEmptyString, + }), +}); + +export const BitbucketPullRequestSchema = Schema.Struct({ + id: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + links: Schema.Struct({ + html: Schema.Struct({ + href: TrimmedNonEmptyString, + }), + }), + source: BitbucketPullRequestBranchSchema, + destination: BitbucketPullRequestBranchSchema, +}); + +export const BitbucketPullRequestListSchema = Schema.Struct({ + values: Schema.Array(BitbucketPullRequestSchema), + next: Schema.optional(TrimmedNonEmptyString), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function repositoryOwner(repository: Schema.Schema.Type) { + return ( + trimOptionalString(repository.workspace?.slug) ?? + (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) + ); +} + +function normalizeBitbucketPullRequestState(state: string | null | undefined) { + switch (state?.trim().toUpperCase()) { + case "MERGED": + return "merged" as const; + case "DECLINED": + case "SUPERSEDED": + return "closed" as const; + case "OPEN": + default: + return "open" as const; + } +} + +export function normalizeBitbucketPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedBitbucketPullRequestRecord { + const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); + const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); + const headRepositoryOwnerLogin = raw.source.repository + ? repositoryOwner(raw.source.repository) + : null; + const isCrossRepository = + headRepositoryNameWithOwner !== null && + baseRepositoryNameWithOwner !== null && + headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; + + return { + number: raw.id, + title: raw.title, + url: raw.links.html.href, + baseRefName: raw.destination.branch.name, + headRefName: raw.source.branch.name, + state: normalizeBitbucketPullRequestState(raw.state), + updatedAt: raw.updated_on ?? Option.none(), + ...(isCrossRepository ? { isCrossRepository: true } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} diff --git a/apps/server/src/git/githubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts similarity index 90% rename from apps/server/src/git/githubPullRequests.ts rename to apps/server/src/sourceControl/gitHubPullRequests.ts index d137a46d6fa..d9dcb7f9ad1 100644 --- a/apps/server/src/git/githubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -1,4 +1,9 @@ -import { Cause, Exit, Result, Schema } from "effect"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; @@ -9,7 +14,7 @@ export interface NormalizedGitHubPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; + readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; @@ -23,7 +28,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), - updatedAt: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), headRepository: Schema.optional( Schema.NullOr( @@ -80,8 +85,7 @@ function normalizeGitHubPullRequestRecord( baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), - updatedAt: - typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null, + updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } : {}), diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts new file mode 100644 index 00000000000..afd1eceaeed --- /dev/null +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -0,0 +1,153 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitLabMergeRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const GitLabProjectReferenceSchema = Schema.Struct({ + path_with_namespace: Schema.optional(Schema.String), + pathWithNamespace: Schema.optional(Schema.String), + namespace: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.String), + full_path: Schema.optional(Schema.String), + fullPath: Schema.optional(Schema.String), + }), + ), + ), +}); + +const GitLabMergeRequestSchema = Schema.Struct({ + iid: PositiveInt, + title: TrimmedNonEmptyString, + web_url: TrimmedNonEmptyString, + source_branch: TrimmedNonEmptyString, + target_branch: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), + target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), + source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), + target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeGitLabMergeRequestState( + state: string | null | undefined, +): "open" | "closed" | "merged" { + const normalized = state?.trim().toLowerCase(); + if (normalized === "merged") { + return "merged"; + } + if (normalized === "closed") { + return "closed"; + } + return "open"; +} + +function projectPathWithNamespace( + project: Schema.Schema.Type | null | undefined, +): string | null { + const explicit = + trimOptionalString(project?.path_with_namespace) ?? + trimOptionalString(project?.pathWithNamespace); + if (explicit) { + return explicit; + } + + const namespacePath = + trimOptionalString(project?.namespace?.full_path) ?? + trimOptionalString(project?.namespace?.fullPath) ?? + trimOptionalString(project?.namespace?.path); + return namespacePath; +} + +function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null { + const [owner] = pathWithNamespace?.split("/") ?? []; + return trimOptionalString(owner); +} + +function normalizeGitLabMergeRequestRecord( + raw: Schema.Schema.Type, +): NormalizedGitLabMergeRequestRecord { + const sourceProjectPath = projectPathWithNamespace(raw.source_project); + const targetProjectPath = projectPathWithNamespace(raw.target_project); + const isCrossRepository = + typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number" + ? raw.source_project_id !== raw.target_project_id + : sourceProjectPath !== null && targetProjectPath !== null + ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase() + : undefined; + const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath); + + return { + number: raw.iid, + title: raw.title, + url: raw.web_url, + baseRefName: raw.target_branch, + headRefName: raw.source_branch, + state: normalizeGitLabMergeRequestState(raw.state), + updatedAt: raw.updated_at ?? Option.none(), + ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), + ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); +const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); + +export const formatGitLabJsonDecodeError = formatSchemaError; + +export function decodeGitLabMergeRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeGitLabMergeRequestList(raw); + if (Result.isSuccess(result)) { + const mergeRequests: NormalizedGitLabMergeRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeGitLabMergeRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value)); + } + return Result.succeed(mergeRequests); + } + return Result.fail(result.failure); +} + +export function decodeGitLabMergeRequestJson( + raw: string, +): Result.Result> { + const result = decodeGitLabMergeRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeGitLabMergeRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index ef6ece31e28..03c01170f15 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -7,7 +7,7 @@ import { resolveHeadlessConnectionHost, resolveHeadlessConnectionString, resolveListeningPort, -} from "./startupAccess"; +} from "./startupAccess.ts"; it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index a350d729d01..d3b6898d75b 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,11 +1,11 @@ import { networkInterfaces } from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerConfig } from "./config.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; export interface HeadlessServeAccessInfo { readonly connectionString: string; diff --git a/apps/server/src/stream/collectUint8StreamText.test.ts b/apps/server/src/stream/collectUint8StreamText.test.ts new file mode 100644 index 00000000000..d6715294cce --- /dev/null +++ b/apps/server/src/stream/collectUint8StreamText.test.ts @@ -0,0 +1,39 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { collectUint8StreamText } from "./collectUint8StreamText.ts"; + +const encoder = new TextEncoder(); + +describe("collectUint8StreamText", () => { + it.effect("collects Uint8Array chunks into decoded text", () => + Effect.gen(function* () { + const result = yield* collectUint8StreamText({ + stream: Stream.make(encoder.encode("hello "), encoder.encode("world")), + }); + + assert.deepStrictEqual(result, { + text: "hello world", + bytes: 11, + truncated: false, + }); + }), + ); + + it.effect("truncates by bytes and appends an optional marker once", () => + Effect.gen(function* () { + const result = yield* collectUint8StreamText({ + stream: Stream.make(encoder.encode("abcdef"), encoder.encode("ghij")), + maxBytes: 5, + truncatedMarker: "[truncated]", + }); + + assert.deepStrictEqual(result, { + text: "abcde[truncated]", + bytes: 5, + truncated: true, + }); + }), + ); +}); diff --git a/apps/server/src/stream/collectUint8StreamText.ts b/apps/server/src/stream/collectUint8StreamText.ts new file mode 100644 index 00000000000..7ac5530474e --- /dev/null +++ b/apps/server/src/stream/collectUint8StreamText.ts @@ -0,0 +1,70 @@ +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +export interface CollectedUint8StreamText { + readonly text: string; + readonly truncated: boolean; + readonly bytes: number; +} + +interface CollectState { + chunks: Uint8Array[]; + readonly bytes: number; + readonly truncated: boolean; +} + +export const collectUint8StreamText = (input: { + readonly stream: Stream.Stream; + readonly maxBytes?: number | undefined; + readonly truncatedMarker?: string | null | undefined; +}): Effect.Effect => { + const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY; + const truncatedMarker = input.truncatedMarker ?? ""; + + return input.stream.pipe( + Stream.runFold( + (): CollectState => ({ + chunks: [], + bytes: 0, + truncated: false, + }), + (state, chunk): CollectState => { + /* + * keep draining after truncation so the child process can exit normally. + * its a know issue that on windows killing after the output cap can force an expensive taskkill operation and hurt performance + */ + if (state.truncated) { + return state; + } + + const remainingBytes = maxBytes - state.bytes; + if (remainingBytes <= 0) { + return { + ...state, + truncated: true, + }; + } + + const nextChunk = + chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; + state.chunks.push(nextChunk); + const bytes = state.bytes + nextChunk.byteLength; + const truncated = chunk.byteLength > remainingBytes; + + return { + chunks: state.chunks, + bytes, + truncated, + }; + }, + ), + Effect.map((state): CollectedUint8StreamText => { + const text = Buffer.concat(state.chunks, state.bytes).toString("utf8"); + return { + text: state.truncated && truncatedMarker.length > 0 ? `${text}${truncatedMarker}` : text, + bytes: state.bytes, + truncated: state.truncated, + }; + }), + ); +}; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index d7784eb88b4..ed8e98a87a4 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,7 +1,11 @@ -import { Effect, FileSystem, Path, Random, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; import * as Crypto from "node:crypto"; import { homedir } from "node:os"; -import { ServerConfig } from "../config"; +import { ServerConfig } from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index 5fe0795ce48..03ebd2fdc65 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -1,7 +1,9 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Layer } from "effect"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index e933576dffa..ec859fb18f5 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -7,13 +7,17 @@ * @module AnalyticsServiceLive */ -import { Config, DateTime, Effect, Layer, Ref } from "effect"; +import * as Config from "effect/Config"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; -import { version } from "../../../package.json" with { type: "json" }; +import packageJson from "../../../package.json" with { type: "json" }; interface BufferedAnalyticsEvent { readonly event: string; @@ -86,7 +90,7 @@ const makeAnalyticsService = Effect.gen(function* () { platform: process.platform, wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, - t3CodeVersion: version, + t3CodeVersion: packageJson.version, clientType, }, timestamp: event.capturedAt, diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts index 0e703573d46..a2717c790dc 100644 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ b/apps/server/src/telemetry/Services/AnalyticsService.ts @@ -6,7 +6,9 @@ * * @module AnalyticsService */ -import { Effect, Layer, Context } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Context from "effect/Context"; export interface AnalyticsServiceShape { /** diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index 1fb4bdd6367..40113a1915c 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -1,13 +1,17 @@ -import { Effect, Layer } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { PtyAdapter } from "../Services/PTY.ts"; +import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; class BunPtyProcess implements PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); private readonly decoder = new TextDecoder(); + private readonly process: Bun.Subprocess; private didExit = false; - constructor(private readonly process: Bun.Subprocess) { + constructor(process: Bun.Subprocess) { + this.process = process; void this.process.exited .then((exitCode) => { this.emitExit({ diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 8207861e206..b81d6596592 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -1,5 +1,3 @@ -import path from "node:path"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -8,41 +6,50 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; -import { - Duration, - Effect, - Encoding, - Exit, - Fiber, - FileSystem, - Option, - PlatformError, - Ref, - Schedule, - Scope, -} from "effect"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +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 Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; import { TestClock } from "effect/testing"; import { expect } from "vitest"; -import type { TerminalManagerShape } from "../Services/Manager"; +import * as ProcessRunner from "../../processRunner.ts"; +import type { TerminalManagerShape } from "../Services/Manager.ts"; import { type PtyAdapterShape, type PtyExitEvent, type PtyProcess, type PtySpawnInput, PtySpawnError, -} from "../Services/PTY"; -import { makeTerminalManagerWithOptions } from "./Manager"; +} from "../Services/PTY.ts"; +import { makeTerminalManagerWithOptions } from "./Manager.ts"; + +class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ + readonly message: string; +}> {} class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; + readonly pid: number; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; - constructor(readonly pid: number) {} + constructor(pid: number) { + this.pid = pid; + } write(data: string): void { this.writes.push(data); @@ -88,9 +95,12 @@ class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; + private readonly mode: "sync" | "async"; private nextPid = 9000; - constructor(private readonly mode: "sync" | "async" = "sync") {} + constructor(mode: "sync" | "async" = "sync") { + this.mode = mode; + } spawn(input: PtySpawnInput): Effect.Effect { this.spawnInputs.push(input); @@ -124,17 +134,18 @@ class FakePtyAdapter implements PtyAdapterShape { const waitFor = ( predicate: Effect.Effect, timeout: Duration.Input = 800, -): Effect.Effect => +): Effect.Effect => predicate.pipe( Effect.filterOrFail( (done) => done, - () => new Error("Condition not met"), + () => new WaitForConditionError({ message: "Condition not met" }), ), Effect.retry(Schedule.spaced("15 millis")), Effect.timeoutOption(timeout), Effect.flatMap((result) => Option.match(result, { - onNone: () => Effect.fail(new Error("Timed out waiting for condition")), + onNone: () => + Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), onSome: () => Effect.void, }), ), @@ -162,32 +173,32 @@ function restartInput(overrides: Partial = {}): TerminalRe }; } -function historyLogName(threadId: string): string { - return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; -} - -function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { - const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; - if (terminalId === DEFAULT_TERMINAL_ID) { - return `${threadPart}.log`; - } - return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; -} - -function historyLogPath(logsDir: string, threadId = "thread-1"): string { - return path.join(logsDir, historyLogName(threadId)); -} +const historyLogPath = (logsDir: string, threadId = "thread-1") => + Effect.service(Path.Path).pipe( + Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), + ); -function multiTerminalHistoryLogPath( +const multiTerminalHistoryLogPath = ( logsDir: string, threadId = "thread-1", - terminalId = "default", -): string { - return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); -} + terminalId = DEFAULT_TERMINAL_ID, +) => + Effect.service(Path.Path).pipe( + Effect.map(({ join }) => { + const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; + return join( + logsDir, + terminalId === DEFAULT_TERMINAL_ID + ? `${threadPart}.log` + : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, + ); + }), + ); interface CreateManagerOptions { shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: (terminalPid: number) => Effect.Effect; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -209,12 +220,13 @@ const createManager = ( ): Effect.Effect< ManagerFixture, PlatformError.PlatformError, - FileSystem.FileSystem | Scope.Scope + FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner > => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => Effect.gen(function* () { + const { join } = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); - const logsDir = path.join(baseDir, "userdata", "logs", "terminals"); + const logsDir = join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); const manager = yield* makeTerminalManagerWithOptions({ @@ -222,15 +234,15 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.platform !== undefined ? { platform: options.platform } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), - ...(options.processKillGraceMs !== undefined - ? { processKillGraceMs: options.processKillGraceMs } - : {}), + processKillGraceMs: options.processKillGraceMs ?? 1, ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), @@ -245,6 +257,7 @@ const createManager = ( return { baseDir, logsDir, + join, ptyAdapter, manager, getEvents: Ref.get(eventsRef), @@ -252,7 +265,12 @@ const createManager = ( }), ); -it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (it) => { +it.layer( + Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), + { excludeTestServices: true }, +)("TerminalManager", (it) => { + const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; + it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -289,8 +307,10 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( fs.writeFileString(filePath, contents), ); - it.effect("preserves non-notFound cwd stat failures", () => + itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { + const path = yield* Path.Path; + const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); @@ -387,15 +407,27 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("clears transcript and emits cleared event", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); + const path = yield* Path.Path; yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("hello\n"); - yield* waitFor(pathExists(historyLogPath(logsDir))); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); - yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(readFileString), + Effect.map((text) => text === ""), + ), + ); const events = yield* getEvents; expect(events.some((event) => event.type === "cleared")).toBe(true); @@ -418,19 +450,32 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( expect(firstProcess).toBeDefined(); if (!firstProcess) return; firstProcess.emitData("before restart\n"); - yield* waitFor(pathExists(historyLogPath(logsDir))); + const path = yield* Path.Path; + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); const snapshot = yield* manager.restart(restartInput()); assert.equal(snapshot.history, ""); assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs).toHaveLength(2); - yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(readFileString), + Effect.map((text) => text === ""), + ), + ); }), ); it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => Effect.gen(function* () { const { manager, getEvents, baseDir } = yield* createManager(); + const path = yield* Path.Path; const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); yield* makeDirectory(firstWorktreePath); @@ -468,6 +513,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("preserves worktree metadata when reopening an exited session", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); + const path = yield* Path.Path; const worktreePath = path.join(baseDir, "worktrees", "feature-a"); yield* makeDirectory(worktreePath); @@ -510,12 +556,18 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("emits exited event and reopens with clean transcript after exit", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); + const path = yield* Path.Path; yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; process.emitData("old data\n"); - yield* waitFor(pathExists(historyLogPath(logsDir))); + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); process.emitExit({ exitCode: 0, signal: 0 }); yield* waitFor( @@ -525,7 +577,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( assert.equal(reopened.history, ""); expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect(yield* readFileString(historyLogPath(logsDir))).toBe(""); + expect( + yield* historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(readFileString), + ), + ).toBe(""); }), ); @@ -712,10 +769,21 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( expect(process).toBeDefined(); if (!process) return; process.emitData("bye\n"); - yield* waitFor(pathExists(historyLogPath(logsDir))); + const path = yield* Path.Path; + yield* waitFor( + historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - expect(yield* pathExists(historyLogPath(logsDir))).toBe(false); + expect( + yield* historyLogPath(logsDir).pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ).toBe(false); }), ); @@ -732,19 +800,36 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( defaultProcess.emitData("default\n"); sidecarProcess.emitData("sidecar\n"); - yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); - yield* waitFor(pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); + const path = yield* Path.Path; + yield* waitFor( + multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); + yield* waitFor( + multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); yield* manager.close({ threadId: "thread-1", deleteHistory: true }); assert.equal(defaultProcess.killed, true); assert.equal(sidecarProcess.killed, true); - expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe( - false, - ); - expect(yield* pathExists(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe( - false, - ); + expect( + yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ).toBe(false); + expect( + yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ).toBe(false); }), ); @@ -783,7 +868,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( first.emitData("first-history\n"); second.emitData("second-history\n"); - yield* waitFor(pathExists(historyLogPath(logsDir, "thread-1"))); + const path = yield* Path.Path; + yield* waitFor( + historyLogPath(logsDir, "thread-1").pipe( + Effect.provideService(Path.Path, path), + Effect.flatMap(pathExists), + ), + ); first.emitExit({ exitCode: 0, signal: 0 }); yield* Effect.sleep(Duration.millis(5)); second.emitExit({ exitCode: 0, signal: 0 }); @@ -806,8 +897,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => Effect.gen(function* () { const { manager, logsDir } = yield* createManager(); + const path = yield* Path.Path; const legacyPath = path.join(logsDir, "thread-1.log"); - const nextPath = historyLogPath(logsDir); + const nextPath = yield* historyLogPath(logsDir); yield* writeFileString(legacyPath, "legacy-line\n"); const snapshot = yield* manager.open(openInput()); @@ -821,8 +913,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { + const missingShell = + process.platform === "win32" + ? "C:\\definitely\\missing-shell.exe" + : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/definitely/missing-shell -l", + shellResolver: () => missingShell, }); ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); @@ -830,12 +926,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); - expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); + expect(ptyAdapter.spawnInputs[0]?.shell).toBe( + process.platform === "win32" ? missingShell : "/definitely/missing-shell", + ); if (process.platform === "win32") { expect( ptyAdapter.spawnInputs.some( - (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", + (input) => + input.shell === "pwsh.exe" || + input.shell === "powershell.exe" || + input.shell === "cmd.exe", ), ).toBe(true); } else { @@ -848,6 +949,56 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("prefers PowerShell over ComSpec for Windows terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs[0]).toEqual( + expect.objectContaining({ + shell: "pwsh.exe", + args: ["-NoLogo"], + }), + ); + }), + ); + + it.effect("falls back to built-in PowerShell by absolute path on Windows", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + shellResolver: () => "C:\\missing\\custom-shell.exe", + }); + ptyAdapter.spawnFailures.push( + new Error("spawn custom-shell.exe ENOENT"), + new Error("spawn pwsh.exe ENOENT"), + ); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "C:\\missing\\custom-shell.exe", + "pwsh.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ]); + expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); + expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const originalValues = new Map(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 4bdeba68e16..9f20ebc8315 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1,5 +1,3 @@ -import path from "node:path"; - import { DEFAULT_TERMINAL_ID, type TerminalEvent, @@ -7,28 +5,28 @@ import { type TerminalSessionStatus, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; -import { - Effect, - Encoding, - Equal, - Exit, - Fiber, - FileSystem, - Layer, - Option, - Schema, - Scope, - Semaphore, - SynchronizedRef, -} from "effect"; - -import { ServerConfig } from "../../config"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +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 * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { ServerConfig } from "../../config.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, -} from "../../observability/Metrics"; -import { runProcess } from "../../processRunner"; +} from "../../observability/Metrics.ts"; +import * as ProcessRunner from "../../processRunner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -36,14 +34,14 @@ import { TerminalNotRunningError, TerminalSessionLookupError, type TerminalManagerShape, -} from "../Services/Manager"; +} from "../Services/Manager.ts"; import { PtyAdapter, PtySpawnError, type PtyAdapterShape, type PtyExitEvent, type PtyProcess, -} from "../Services/PTY"; +} from "../Services/PTY.ts"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; @@ -53,6 +51,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -74,7 +73,9 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass; + ( + terminalPid: number, + ): Effect.Effect; } interface ShellCandidate { @@ -186,19 +187,25 @@ function enqueueProcessEvent( return true; } -function defaultShellResolver(): string { - if (process.platform === "win32") { - return process.env.ComSpec ?? "cmd.exe"; +function defaultShellResolver( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + if (platform === "win32") { + return "pwsh.exe"; } - return process.env.SHELL ?? "bash"; + return env.SHELL ?? "bash"; } -function normalizeShellCommand(value: string | undefined): string | null { +function normalizeShellCommand( + value: string | undefined, + platform: NodeJS.Platform = process.platform, +): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; - if (process.platform === "win32") { + if (platform === "win32") { return trimmed; } @@ -207,15 +214,58 @@ function normalizeShellCommand(value: string | undefined): string | null { return firstToken.replace(/^['"]|['"]$/g, ""); } -function shellCandidateFromCommand(command: string | null): ShellCandidate | null { +function basenameForPlatform(command: string, platform: NodeJS.Platform): string { + const normalized = + platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); + const parts = normalized + .split(platform === "win32" ? /\\+/ : /\/+/) + .filter((part) => part.length > 0); + return parts.at(-1) ?? normalized; +} + +function joinWindowsPath(...parts: ReadonlyArray): string { + return parts + .map((part, index) => { + if (index === 0) return part.replace(/[\\/]+$/g, ""); + return part.replace(/^[\\/]+|[\\/]+$/g, ""); + }) + .filter((part) => part.length > 0) + .join("\\"); +} + +function shellCandidateFromCommand( + command: string | null, + platform: NodeJS.Platform = process.platform, +): ShellCandidate | null { if (!command || command.length === 0) return null; - const shellName = path.basename(command).toLowerCase(); - if (process.platform !== "win32" && shellName === "zsh") { + const shellName = basenameForPlatform(command, platform).toLowerCase(); + if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { + return { shell: command, args: ["-NoLogo"] }; + } + if (platform !== "win32" && shellName === "zsh") { return { shell: command, args: ["-o", "nopromptsp"] }; } return { shell: command }; } +function windowsSystemRoot(env: NodeJS.ProcessEnv): string { + return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; +} + +function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return joinWindowsPath( + windowsSystemRoot(env), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +function windowsCmdPath(env: NodeJS.ProcessEnv): string { + return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); +} + function formatShellCandidate(candidate: ShellCandidate): string { if (!candidate.args || candidate.args.length === 0) return candidate.shell; return `${candidate.shell} ${candidate.args.join(" ")}`; @@ -234,27 +284,37 @@ function uniqueShellCandidates(candidates: Array): ShellC return ordered; } -function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { - const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); +function resolveShellCandidates( + shellResolver: () => string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): ShellCandidate[] { + const requested = shellCandidateFromCommand( + normalizeShellCommand(shellResolver(), platform), + platform, + ); - if (process.platform === "win32") { + if (platform === "win32") { return uniqueShellCandidates([ requested, - shellCandidateFromCommand(process.env.ComSpec ?? null), - shellCandidateFromCommand("powershell.exe"), - shellCandidateFromCommand("cmd.exe"), + shellCandidateFromCommand("pwsh.exe", platform), + shellCandidateFromCommand(windowsPowerShellPath(env), platform), + shellCandidateFromCommand("powershell.exe", platform), + shellCandidateFromCommand(env.ComSpec ?? null, platform), + shellCandidateFromCommand(windowsCmdPath(env), platform), + shellCandidateFromCommand("cmd.exe", platform), ]); } return uniqueShellCandidates([ requested, - shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), - shellCandidateFromCommand("/bin/zsh"), - shellCandidateFromCommand("/bin/bash"), - shellCandidateFromCommand("/bin/sh"), - shellCandidateFromCommand("zsh"), - shellCandidateFromCommand("bash"), - shellCandidateFromCommand("sh"), + shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), + shellCandidateFromCommand("/bin/zsh", platform), + shellCandidateFromCommand("/bin/bash", platform), + shellCandidateFromCommand("/bin/sh", platform), + shellCandidateFromCommand("zsh", platform), + shellCandidateFromCommand("bash", platform), + shellCandidateFromCommand("sh", platform), ]); } @@ -306,66 +366,82 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { function checkWindowsSubprocessActivity( terminalPid: number, -): Effect.Effect { +): Effect.Effect { const command = [ `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, "if ($children) { exit 0 }", "exit 1", ].join("; "); - return Effect.tryPromise({ - try: () => - runProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { - timeoutMs: 1_500, - allowNonZeroExit: true, - maxBufferBytes: 32_768, - outputMode: "truncate", - }), - catch: (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to check Windows terminal subprocess activity.", - cause, - terminalPid, - command: "powershell", - }), - }).pipe(Effect.map((result) => result.code === 0)); + return Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner.run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: "1500 millis", + maxOutputBytes: 32_768, + outputMode: "truncate", + shell: process.platform === "win32", + timeoutBehavior: "timedOutResult", + }); + }).pipe( + Effect.map((result) => result.code === 0), + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + message: "Failed to check Windows terminal subprocess activity.", + cause, + terminalPid, + command: "powershell", + }), + ), + ); } const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* ( terminalPid: number, -): Effect.fn.Return { - const runPgrep = Effect.tryPromise({ - try: () => - runProcess("pgrep", ["-P", String(terminalPid)], { - timeoutMs: 1_000, - allowNonZeroExit: true, - maxBufferBytes: 32_768, - outputMode: "truncate", - }), - catch: (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with pgrep.", - cause, - terminalPid, - command: "pgrep", - }), - }); +): Effect.fn.Return { + const processRunner = yield* ProcessRunner.ProcessRunner; + const runPgrep = processRunner + .run({ + command: "pgrep", + args: ["-P", String(terminalPid)], + timeout: "1 second", + maxOutputBytes: 32_768, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + message: "Failed to inspect terminal subprocesses with pgrep.", + cause, + terminalPid, + command: "pgrep", + }), + ), + ); - const runPs = Effect.tryPromise({ - try: () => - runProcess("ps", ["-eo", "pid=,ppid="], { - timeoutMs: 1_000, - allowNonZeroExit: true, - maxBufferBytes: 262_144, - outputMode: "truncate", - }), - catch: (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with ps.", - cause, - terminalPid, - command: "ps", - }), - }); + const runPs = processRunner + .run({ + command: "ps", + args: ["-eo", "pid=,ppid="], + timeout: "1 second", + maxOutputBytes: 262_144, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + message: "Failed to inspect terminal subprocesses with ps.", + cause, + terminalPid, + command: "ps", + }), + ), + ); const pgrepResult = yield* Effect.exit(runPgrep); if (pgrepResult._tag === "Success") { @@ -396,7 +472,7 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( terminalPid: number, -): Effect.fn.Return { +): Effect.fn.Return { if (!Number.isInteger(terminalPid) || terminalPid <= 0) { return false; } @@ -651,6 +727,8 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: TerminalSubprocessChecker; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -669,13 +747,22 @@ const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( function* (options: TerminalManagerOptions) { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const shellResolver = options.shellResolver ?? defaultShellResolver; - const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; + const platform = options.platform ?? process.platform; + const baseEnv = options.env ?? process.env; + const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); + const processRunner = yield* ProcessRunner.ProcessRunner; + const subprocessChecker = + options.subprocessChecker ?? + ((terminalPid) => + defaultSubprocessChecker(terminalPid).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + )); const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; @@ -1113,6 +1200,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith expectedPid: number, ) { while (true) { + const updatedAt = yield* nowIso; const action: DrainProcessEventAction = yield* Effect.sync(() => { if (session.pid !== expectedPid || !session.process || session.status !== "running") { session.pendingProcessEvents = []; @@ -1147,7 +1235,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith historyLineLimit, ); } - session.updatedAt = new Date().toISOString(); + session.updatedAt = updatedAt; return { type: "output", @@ -1174,7 +1262,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.exitSignal = Number.isInteger(nextEvent.event.signal) ? nextEvent.event.signal : null; - session.updatedAt = new Date().toISOString(); + session.updatedAt = updatedAt; return { type: "exit", @@ -1195,22 +1283,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith yield* queuePersist(action.threadId, action.terminalId, action.history); } + const createdAt = yield* nowIso; yield* publishEvent({ type: "output", threadId: action.threadId, terminalId: action.terminalId, - createdAt: new Date().toISOString(), + createdAt, data: action.data, }); continue; } yield* clearKillFiber(action.process); + const createdAt = yield* nowIso; yield* publishEvent({ type: "exited", threadId: action.threadId, terminalId: action.terminalId, - createdAt: new Date().toISOString(), + createdAt, exitCode: action.exitCode, exitSignal: action.exitSignal, }); @@ -1225,6 +1315,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const process = session.process; if (!process) return; + const updatedAt = yield* nowIso; yield* modifyManagerState((state) => { cleanupProcessHandles(session); session.process = null; @@ -1235,7 +1326,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - session.updatedAt = new Date().toISOString(); + session.updatedAt = updatedAt; return [undefined, state] as const; }); @@ -1314,6 +1405,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith "terminal.cwd": input.cwd, }); + const startingAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "starting"; session.cwd = input.cwd; @@ -1326,7 +1418,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - session.updatedAt = new Date().toISOString(); + session.updatedAt = startingAt; return [undefined, state] as const; }); @@ -1337,8 +1429,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); + const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; @@ -1357,21 +1449,23 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith runFork(drainProcessEvents(session, processPid)); }); + const runningAt = yield* nowIso; yield* modifyManagerState((state) => { session.process = ptyProcess; session.pid = processPid; session.status = "running"; - session.updatedAt = new Date().toISOString(); + session.updatedAt = runningAt; session.unsubscribeData = unsubscribeData; session.unsubscribeExit = unsubscribeExit; return [undefined, state] as const; }); + const createdAt = yield* nowIso; yield* publishEvent({ type: eventType, threadId: session.threadId, terminalId: session.terminalId, - createdAt: new Date().toISOString(), + createdAt, snapshot: snapshot(session), }); }), @@ -1388,6 +1482,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (ptyProcess) { yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); } + const erroredAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "error"; @@ -1399,18 +1494,19 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - session.updatedAt = new Date().toISOString(); + session.updatedAt = erroredAt; return [undefined, state] as const; }); yield* evictInactiveSessionsIfNeeded(); const message = error.message; + const createdAt = yield* nowIso; yield* publishEvent({ type: "error", threadId: session.threadId, terminalId: session.terminalId, - createdAt: new Date().toISOString(), + createdAt, message, }); yield* Effect.logError("failed to start terminal", { @@ -1482,6 +1578,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return; } + const updatedAt = yield* nowIso; + const createdAt = yield* nowIso; const event = yield* modifyManagerState((state) => { const liveSession: Option.Option = Option.fromNullishOr( state.sessions.get(toSessionKey(session.threadId, session.terminalId)), @@ -1496,14 +1594,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value; - liveSession.value.updatedAt = new Date().toISOString(); + liveSession.value.updatedAt = updatedAt; return [ Option.some({ type: "activity" as const, threadId: liveSession.value.threadId, terminalId: liveSession.value.terminalId, - createdAt: new Date().toISOString(), + createdAt, hasRunningSubprocess: hasRunningSubprocess.value, }), state, @@ -1582,6 +1680,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const history = yield* readHistory(input.threadId, terminalId); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; + const createdAt = yield* nowIso; const session: TerminalSessionState = { threadId: input.threadId, terminalId, @@ -1596,7 +1695,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith processEventDrainRunning: false, exitCode: null, exitSignal: null, - updatedAt: new Date().toISOString(), + updatedAt: createdAt, cols, rows, process: null, @@ -1687,7 +1786,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { liveSession.cols = targetCols; liveSession.rows = targetRows; - liveSession.updatedAt = new Date().toISOString(); + liveSession.updatedAt = yield* nowIso; liveSession.process.resize(targetCols, targetRows); } @@ -1721,7 +1820,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } session.cols = input.cols; session.rows = input.rows; - session.updatedAt = new Date().toISOString(); + session.updatedAt = yield* nowIso; yield* Effect.sync(() => process.resize(input.cols, input.rows)); }); @@ -1731,18 +1830,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.gen(function* () { const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; const session = yield* requireSession(input.threadId, terminalId); + const updatedAt = yield* nowIso; session.history = ""; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - session.updatedAt = new Date().toISOString(); + session.updatedAt = updatedAt; yield* persistHistory(input.threadId, terminalId, session.history); + const createdAt = yield* nowIso; yield* publishEvent({ type: "cleared", threadId: input.threadId, terminalId, - createdAt: new Date().toISOString(), + createdAt, }); }), ); @@ -1761,6 +1862,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (Option.isNone(existingSession)) { const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; + const createdAt = yield* nowIso; session = { threadId: input.threadId, terminalId, @@ -1775,7 +1877,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith processEventDrainRunning: false, exitCode: null, exitSignal: null, - updatedAt: new Date().toISOString(), + updatedAt: createdAt, cols, rows, process: null, @@ -1865,4 +1967,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }, ); -export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()); +export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 58fcc70e4e5..15d24360f7e 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -1,7 +1,9 @@ -import { FileSystem, Path, Effect } from "effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Effect from "effect/Effect"; import { assert, it } from "@effect/vitest"; -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; +import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index cf1fdd21982..c81d76f5d1e 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -1,7 +1,16 @@ import { createRequire } from "node:module"; -import { Effect, FileSystem, Layer, Path } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { PtyAdapter } from "../Services/PTY.ts"; +import { + PtySpawnError, + type PtyAdapterShape, + type PtyExitEvent, + type PtyProcess, +} from "../Services/PTY.ts"; let didEnsureSpawnHelperExecutable = false; @@ -46,7 +55,11 @@ export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitP }); class NodePtyProcess implements PtyProcess { - constructor(private readonly process: import("node-pty").IPty) {} + private readonly process: import("node-pty").IPty; + + constructor(process: import("node-pty").IPty) { + this.process = process; + } get pid(): number { return this.process.pid; @@ -103,12 +116,21 @@ export const layer = Layer.effect( return { spawn: Effect.fn(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + const ptyProcess = yield* Effect.try({ + try: () => + nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + }), + catch: (cause) => + new PtySpawnError({ + adapter: "node-pty", + message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", + cause, + }), }); return new NodePtyProcess(ptyProcess); }), diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index b59c4721cd3..6db23d571b2 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -22,8 +22,9 @@ import { TerminalSessionStatus, TerminalWriteInput, } from "@t3tools/contracts"; -import { PtyProcess } from "./PTY"; -import { Effect, Context } from "effect"; +import type { PtyProcess } from "./PTY.ts"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; export { TerminalCwdError, diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/Services/PTY.ts index 091e527ef2b..93cd1b0d47b 100644 --- a/apps/server/src/terminal/Services/PTY.ts +++ b/apps/server/src/terminal/Services/PTY.ts @@ -6,7 +6,9 @@ * * @module PtyAdapter */ -import { Effect, Schema, Context } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; /** * PtyError - Error type for PTY adapter operations. diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts new file mode 100644 index 00000000000..9ee546728d0 --- /dev/null +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -0,0 +1,345 @@ +import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vitest"; + +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-claude-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeFakeClaudeBinary(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(dir, "bin"); + const claudePath = path.join(binDir, "claude"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + yield* fs.writeFileString( + claudePath, + [ + "#!/bin/sh", + 'args="$*"', + 'stdin_content="$(cat)"', + 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', + ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "args missing expected content" >&2', + " exit 2", + " }", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', + ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', + ' printf "%s\\n" "args contained forbidden content" >&2', + " exit 3", + " fi", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', + ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 4", + " }", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', + ' printf "%s\\n" "HOME was $HOME" >&2', + " exit 5", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', + ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', + "fi", + 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', + 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', + "", + ].join("\n"), + ); + yield* fs.chmod(claudePath, 0o755); + return binDir; + }); +} + +function withFakeClaudeEnv( + input: { + output: string; + exitCode?: number; + stderr?: string; + argsMustContain?: string; + argsMustNotContain?: string; + stdinMustContain?: string; + homeMustBe?: string; + claudeConfig?: Partial; + }, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); + const binDir = yield* makeFakeClaudeBinary(tempDir); + const previousPath = process.env.PATH; + const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; + const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; + const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; + const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env.PATH = `${binDir}:${previousPath ?? ""}`; + process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; + + if (input.exitCode !== undefined) { + process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); + } else { + delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; + } + + if (input.stderr !== undefined) { + process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; + } else { + delete process.env.T3_FAKE_CLAUDE_STDERR; + } + + if (input.argsMustContain !== undefined) { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; + } else { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + } + + if (input.argsMustNotContain !== undefined) { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; + } else { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + } + + if (input.stdinMustContain !== undefined) { + process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; + } else { + delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + } + + if (input.homeMustBe !== undefined) { + process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; + } else { + delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + } + }), + () => + Effect.sync(() => { + process.env.PATH = previousPath; + + if (previousOutput === undefined) { + delete process.env.T3_FAKE_CLAUDE_OUTPUT; + } else { + process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; + } + + if (previousExitCode === undefined) { + delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; + } else { + process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; + } + + if (previousStderr === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDERR; + } else { + process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; + } + + if (previousArgsMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; + } + + if (previousArgsMustNotContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; + } + + if (previousStdinMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; + } + + if (previousHomeMustBe === undefined) { + delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + } else { + process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; + } + }), + ); + + const config = decodeClaudeSettings(input.claudeConfig ?? {}); + const textGeneration = yield* makeClaudeTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { + it.effect("forwards Claude thinking settings for Haiku without passing effort", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + subject: "Add important change", + body: "", + }, + }), + argsMustContain: '--settings {"alwaysThinkingEnabled":false}', + argsMustNotContain: "--effort", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/claude-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ]), + }, + }); + + expect(generated.subject).toBe("Add important change"); + }), + ), + ); + + it.effect("forwards Claude fast mode and supported effort", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Improve orchestration flow", + body: "Body", + }, + }), + argsMustContain: '--effort max --settings {"fastMode":true}', + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/claude-effect", + commitSummary: "Improve orchestration", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), + }, + }); + + expect(generated.title).toBe("Improve orchestration flow"); + }), + ), + ); + + it.effect("generates thread titles through the Claude provider", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: + ' "Reconnect failures after restart because the session state does not recover" ', + }, + }), + stdinMustContain: "You write concise thread titles for coding conversations.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate reconnect failures after restarting the session.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe( + sanitizeThreadTitle( + '"Reconnect failures after restart because the session state does not recover"', + ), + ); + }), + ), + ); + + it.effect("runs Claude text generation with the configured Claude HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHome = path.join(process.cwd(), ".claude-work-test"); + return yield* withFakeClaudeEnv( + { + // @effect-diagnostics-next-line preferSchemaOverJson:off + output: JSON.stringify({ + structured_output: { + title: "Use Claude home", + }, + }), + homeMustBe: claudeHome, + claudeConfig: { homePath: claudeHome }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "thread title", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home")); + }), + ); + }), + ); + + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: ' """ """ ', + }, + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); +}); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts similarity index 69% rename from apps/server/src/git/Layers/ClaudeTextGeneration.ts rename to apps/server/src/textGeneration/ClaudeTextGeneration.ts index 99ca21b06d4..3c1e8c69673 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -7,31 +7,41 @@ * * @module ClaudeTextGeneration */ -import { Effect, Layer, Option, Schema, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { ClaudeModelSelection } from "@t3tools/contracts"; -import { resolveApiModelId } from "@t3tools/shared/model"; +import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "../Prompts.ts"; +} from "./TextGenerationPrompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, -} from "../Utils.ts"; -import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; +} from "./TextGenerationUtils.ts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "../provider/Layers/ClaudeProvider.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -43,9 +53,15 @@ const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, }); -const makeClaudeTextGeneration = Effect.gen(function* () { +const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); +const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope)); + +export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( + claudeSettings: ClaudeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverSettingsService = yield* Effect.service(ServerSettingsService); + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const readStreamAsString = ( operation: string, @@ -62,6 +78,26 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ), ); + const encodeJsonForOperation = ( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + value: unknown, + detail: string, + ): Effect.Effect => + encodeJsonString(value).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail, + cause, + }), + ), + ); + /** * Spawn the Claude CLI with structured JSON output and return the parsed, * schema-validated result. @@ -81,28 +117,44 @@ const makeClaudeTextGeneration = Effect.gen(function* () { cwd: string; prompt: string; outputSchemaJson: S; - modelSelection: ClaudeModelSelection; + modelSelection: ModelSelection; }): Effect.fn.Return { - const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); - const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities( - getClaudeModelCapabilities(modelSelection.model), - modelSelection.options, + const jsonSchemaStr = yield* encodeJsonForOperation( + operation, + toJsonSchemaObject(outputSchemaJson), + "Failed to encode structured output schema.", ); + const caps = getClaudeModelCapabilities(modelSelection.model); + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelSelection.options, + }); + const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); + const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); + const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); + const cliEffort = normalizeClaudeCliEffort(resolvedEffort); + const thinkingDescriptor = findDescriptor("thinking"); + const fastModeDescriptor = findDescriptor("fastMode"); + const thinking = + thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; + const fastMode = + fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; const settings = { - ...(typeof normalizedOptions?.thinking === "boolean" - ? { alwaysThinkingEnabled: normalizedOptions.thinking } - : {}), - ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), }; - - const claudeSettings = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => settings.providers.claudeAgent, - ).pipe(Effect.catch(() => Effect.undefined)); + const settingsJson = + Object.keys(settings).length > 0 + ? yield* encodeJsonForOperation( + operation, + settings, + "Failed to encode Claude CLI settings.", + ) + : undefined; const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { const command = ChildProcess.make( - claudeSettings?.binaryPath || "claude", + claudeSettings.binaryPath || "claude", [ "-p", "--output-format", @@ -110,12 +162,13 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - resolveApiModelId(modelSelection), - ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), - ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), + resolveClaudeApiModelId(modelSelection), + ...(cliEffort ? ["--effort", cliEffort] : []), + ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", ], { + env: claudeEnvironment, cwd, shell: process.platform === "win32", stdin: { @@ -175,9 +228,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ), ); - const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( - rawStdout, - ).pipe( + const envelope = yield* decodeClaudeOutputEnvelope(rawStdout).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ @@ -189,7 +240,8 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ), ); - return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( + const decodeOutput = Schema.decodeEffect(outputSchemaJson); + return yield* decodeOutput(envelope.structured_output).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ @@ -216,13 +268,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { includeBranch: input.includeBranch === true, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generateCommitMessage", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generateCommitMessage", cwd: input.cwd, @@ -251,13 +296,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { diffPatch: input.diffPatch, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generatePrContent", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generatePrContent", cwd: input.cwd, @@ -280,13 +318,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generateBranchName", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generateBranchName", cwd: input.cwd, @@ -308,13 +339,6 @@ const makeClaudeTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "claudeAgent") { - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Invalid model selection.", - }); - } - const generated = yield* runClaudeJson({ operation: "generateThreadTitle", cwd: input.cwd, @@ -335,5 +359,3 @@ const makeClaudeTextGeneration = Effect.gen(function* () { generateThreadTitle, } satisfies TextGenerationShape; }); - -export const ClaudeTextGenerationLive = Layer.effect(TextGeneration, makeClaudeTextGeneration); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts similarity index 50% rename from apps/server/src/git/Layers/CodexTextGeneration.test.ts rename to apps/server/src/textGeneration/CodexTextGeneration.test.ts index a07505f025c..87a2f95fbf5 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,29 +1,30 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Result } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; -import { ServerConfig } from "../../config.ts"; -import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; -import { TextGenerationError } from "@t3tools/contracts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; - -const DEFAULT_TEST_MODEL_SELECTION = { - provider: "codex" as const, - model: "gpt-5.4-mini", -}; - -const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-codex-text-generation-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), +import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; +const decodeCodexSettings = Schema.decodeSync(CodexSettings); + +const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.4-mini", ); +const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-codex-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + function makeFakeCodexBinary( dir: string, input: { @@ -118,6 +119,7 @@ function makeFakeCodexBinary( : []), ...(input.stdinMustContain !== undefined ? [ + // @effect-diagnostics-next-line preferSchemaOverJson:off `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, ' printf "%s\\n" "stdin missing expected content" >&2', ` exit 3`, @@ -126,6 +128,7 @@ function makeFakeCodexBinary( : []), ...(input.stdinMustNotContain !== undefined ? [ + // @effect-diagnostics-next-line preferSchemaOverJson:off `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, ' printf "%s\\n" "stdin contained forbidden content" >&2', ` exit 4`, @@ -133,7 +136,10 @@ function makeFakeCodexBinary( ] : []), ...(input.stderr !== undefined - ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + ? [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + `printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`, + ] : []), 'if [ -n "$output_path" ]; then', " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", @@ -161,39 +167,19 @@ function withFakeCodexEnv( stdinMustContain?: string; stdinMustNotContain?: string; }, - effect: Effect.Effect, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, ) { - return Effect.acquireUseRelease( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); - const codexPath = yield* makeFakeCodexBinary(tempDir, input); - const serverSettings = yield* ServerSettingsService; - const previousSettings = yield* serverSettings.getSettings; - yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: codexPath, - }, - }, - }); - return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath }; - }), - () => effect, - ({ serverSettings, previousBinaryPath }) => - serverSettings - .updateSettings({ - providers: { - codex: { - binaryPath: previousBinaryPath, - }, - }, - }) - .pipe(Effect.asVoid), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); + const codexPath = yield* makeFakeCodexBinary(tempDir, input); + const config = decodeCodexSettings({ binaryPath: codexPath }); + const textGeneration = yield* makeCodexTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); } -it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { +it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { @@ -204,22 +190,21 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustNotContain: "branch must be a short semantic git branch fragment", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.subject.length).toBeLessThanOrEqual(72); - expect(generated.subject.endsWith(".")).toBe(false); - expect(generated.body).toBe("- added migration\n- updated tests"); - expect(generated.branch).toBeUndefined(); - }), + expect(generated.subject.length).toBeLessThanOrEqual(72); + expect(generated.subject.endsWith(".")).toBe(false); + expect(generated.body).toBe("- added migration\n- updated tests"); + expect(generated.branch).toBeUndefined(); + }), ), ); @@ -236,24 +221,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { requireReasoningEffort: "xhigh", stdinMustNotContain: "branch must be a short semantic git branch fragment", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - yield* textGeneration.generateCommitMessage({ + (textGeneration) => + textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/codex-effect", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "xhigh", - fastMode: true, - }, - }, - }); - }), + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]), + }), ), ); @@ -266,17 +244,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), requireReasoningEffort: "low", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - yield* textGeneration.generateCommitMessage({ + (textGeneration) => + textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/codex-effect", stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - }), + }), ), ); @@ -290,21 +265,20 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustContain: "branch must be a short semantic git branch fragment", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - includeBranch: true, - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + includeBranch: true, + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.subject).toBe("Add important change"); - expect(generated.branch).toBe("feature/fix/important-system-change"); - }), + expect(generated.subject).toBe("Add important change"); + expect(generated.branch).toBe("feature/fix/important-system-change"); + }), ), ); @@ -316,23 +290,22 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/codex-effect", - commitSummary: "feat: improve orchestration flow", - diffSummary: "2 files changed", - diffPatch: "diff --git a/a.ts b/a.ts", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/codex-effect", + commitSummary: "feat: improve orchestration flow", + diffSummary: "2 files changed", + diffPatch: "diff --git a/a.ts b/a.ts", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("Improve orchestration flow"); - expect(generated.body.startsWith("## Summary")).toBe(true); - expect(generated.body.endsWith("\n\n")).toBe(false); - }), + expect(generated.title).toBe("Improve orchestration flow"); + expect(generated.body.startsWith("## Summary")).toBe(true); + expect(generated.body.endsWith("\n\n")).toBe(false); + }), ), ); @@ -344,17 +317,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustNotContain: "Image attachments supplied to the model", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Please update session handling.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Please update session handling.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.branch).toBe("feat/session"); - }), + expect(generated.branch).toBe("feat/session"); + }), ), ); @@ -366,17 +338,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate websocket reconnect regressions after a worktree restore.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate websocket reconnect regressions after a worktree restore.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); - }), + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + }), ), ); @@ -387,17 +358,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { title: ' """ """ ', }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("New thread"); - }), + expect(generated.title).toBe("New thread"); + }), ), ); @@ -408,17 +378,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { title: ` "' hello world '" `, }), }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.title).toBe("hello world"); - }), + expect(generated.title).toBe("hello world"); + }), ), ); @@ -430,17 +399,16 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), stdinMustNotContain: "Attachment metadata:", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Fix timeout behavior.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Fix timeout behavior.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); - expect(generated.branch).toBe("fix/session-timeout"); - }), + expect(generated.branch).toBe("fix/session-timeout"); + }), ), ); @@ -453,56 +421,17 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { requireImage: true, stdinMustContain: "Attachment metadata:", }, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(attachmentPath, Buffer.from("hello")); - - const textGeneration = yield* TextGeneration; - const generated = yield* textGeneration.generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: attachmentId, - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }); - - expect(generated.branch).toBe("fix/ui-regression"); - }), - ), - ); - - it.effect("resolves persisted attachment ids to files for codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - }, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(imagePath, Buffer.from("hello")); - - const textGeneration = yield* TextGeneration; - const generated = yield* textGeneration - .generateBranchName({ + (textGeneration) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentId = "thread-branch-image-attachment"; + const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); + yield* fs.writeFile(attachmentPath, Buffer.from("hello")); + + const generated = yield* textGeneration.generateBranchName({ modelSelection: DEFAULT_TEST_MODEL_SELECTION, cwd: process.cwd(), message: "Fix layout bug from screenshot.", @@ -515,24 +444,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { sizeBytes: 5, }, ], - }) - .pipe( - Effect.tap(() => - fs.stat(imagePath).pipe( - Effect.map((fileInfo) => { - expect(fileInfo.type).toBe("File"); - }), - ), - ), - Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), - ); + }); - expect(generated.branch).toBe("fix/ui-regression"); - }), + expect(generated.branch).toBe("fix/ui-regression"); + }), ), ); - it.effect("ignores missing attachment ids for codex image inputs", () => + it.effect("resolves persisted attachment ids to files for codex image inputs", () => withFakeCodexEnv( { output: JSON.stringify({ @@ -540,67 +459,115 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), requireImage: true, }, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); - yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); - - const textGeneration = yield* TextGeneration; - const result = yield* textGeneration - .generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: missingAttachmentId, - name: "outside.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("missing --image input"); - } - }), + (textGeneration) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentId = "thread-1-attachment"; + const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); + yield* fs.writeFile(imagePath, Buffer.from("hello")); + + const generated = yield* textGeneration + .generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: attachmentId, + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }) + .pipe( + Effect.tap(() => + fs.stat(imagePath).pipe( + Effect.map((fileInfo) => { + expect(fileInfo.type).toBe("File"); + }), + ), + ), + Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), + ); + + expect(generated.branch).toBe("fix/ui-regression"); + }), ), ); - it.effect( - "fails with typed TextGenerationError when codex returns wrong branch payload shape", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: "This is not a branch payload", - }), - }, + it.effect("ignores missing attachment ids for codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + }, + (textGeneration) => Effect.gen(function* () { - const textGeneration = yield* TextGeneration; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const missingAttachmentId = "thread-missing-attachment"; + const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); + yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); const result = yield* textGeneration .generateBranchName({ - cwd: process.cwd(), - message: "Fix websocket reconnect flake", modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: missingAttachmentId, + name: "outside.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], }) .pipe(Effect.result); expect(Result.isFailure(result)).toBe(true); if (Result.isFailure(result)) { expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("Codex returned invalid structured output"); + expect(result.failure.message).toContain("missing --image input"); } }), + ), + ); + + it.effect( + "fails with typed TextGenerationError when codex returns wrong branch payload shape", + () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: "This is not a branch payload", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateBranchName({ + cwd: process.cwd(), + message: "Fix websocket reconnect flake", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain("Codex returned invalid structured output"); + } + }), ), ); @@ -611,27 +578,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { exitCode: 1, stderr: "codex execution failed", }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-error", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result); - const result = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-error", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain( - "Codex CLI command failed: codex execution failed", - ); - } - }), + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain( + "Codex CLI command failed: codex execution failed", + ); + } + }), ), ); }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts similarity index 76% rename from apps/server/src/git/Layers/CodexTextGeneration.ts rename to apps/server/src/textGeneration/CodexTextGeneration.ts index 52ddf554532..3d1637a7fc0 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -1,45 +1,58 @@ -import { randomUUID } from "node:crypto"; - -import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { CodexModelSelection } from "@t3tools/contracts"; +import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import { TextGenerationError } from "@t3tools/contracts"; import { type BranchNameGenerationInput, type ThreadTitleGenerationResult, type TextGenerationShape, - TextGeneration, -} from "../Services/TextGeneration.ts"; +} from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "../Prompts.ts"; +} from "./TextGenerationPrompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, -} from "../Utils.ts"; -import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { normalizeCodexModelOptionsWithCapabilities } from "@t3tools/shared/model"; +} from "./TextGenerationUtils.ts"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -const makeCodexTextGeneration = Effect.gen(function* () { +const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); +/** + * Build a Codex text-generation closure bound to a specific `CodexSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( + codexConfig: CodexSettings, + environment: NodeJS.ProcessEnv = process.env, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); - const serverSettingsService = yield* Effect.service(ServerSettingsService); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -65,26 +78,47 @@ const makeCodexTextGeneration = Effect.gen(function* () { prefix: string, content: string, ): Effect.Effect => { - return fileSystem - .makeTempFileScoped({ - prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, - }) - .pipe( - Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: `Failed to write temp file`, - cause, - }), - ), - ); + return Effect.gen(function* () { + const tempFileId = yield* Random.nextUUIDv4; + return yield* fileSystem + .makeTempFileScoped({ + prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`, + }) + .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content))); + }).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to write temp file`, + cause, + }), + ), + ); }; const safeUnlink = (filePath: string): Effect.Effect => fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); + const encodeJsonForOperation = ( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + value: unknown, + ): Effect.Effect => + encodeJsonString(value).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Failed to encode structured output schema.", + cause, + }), + ), + ); + const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( _operation: | "generateCommitMessage" @@ -140,39 +174,34 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; - modelSelection: CodexModelSelection; + modelSelection: ModelSelection; }): Effect.fn.Return { - const schemaPath = yield* writeTempFile( + const schemaJson = yield* encodeJsonForOperation( operation, - "codex-schema", - JSON.stringify(toJsonSchemaObject(outputSchemaJson)), + toJsonSchemaObject(outputSchemaJson), ); + const schemaPath = yield* writeTempFile(operation, "codex-schema", schemaJson); const outputPath = yield* writeTempFile(operation, "codex-output", ""); - const codexSettings = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => settings.providers.codex, - ).pipe(Effect.catch(() => Effect.undefined)); - const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { - const normalizedOptions = normalizeCodexModelOptionsWithCapabilities( - getCodexModelCapabilities(modelSelection.model), - modelSelection.options, - ); const reasoningEffort = - modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? + CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( - codexSettings?.binaryPath || "codex", + codexConfig.binaryPath || "codex", [ "exec", "--ephemeral", + "--skip-git-repo-check", "-s", "read-only", "--model", modelSelection.model, "--config", `model_reasoning_effort="${reasoningEffort}"`, - ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []), + ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true + ? ["--config", `service_tier="fast"`] + : []), "--output-schema", schemaPath, "--output-last-message", @@ -182,8 +211,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { ], { env: { - ...process.env, - ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + ...environment, + ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), }, cwd, shell: process.platform === "win32", @@ -250,6 +279,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { ), ); + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* fileSystem.readFileString(outputPath).pipe( Effect.mapError( (cause) => @@ -259,7 +290,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { cause, }), ), - Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), + Effect.flatMap(decodeOutput), Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ @@ -283,13 +314,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { includeBranch: input.includeBranch === true, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generateCommitMessage", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generateCommitMessage", cwd: input.cwd, @@ -318,13 +342,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { diffPatch: input.diffPatch, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generatePrContent", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generatePrContent", cwd: input.cwd, @@ -351,13 +368,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generateBranchName", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generateBranchName", cwd: input.cwd, @@ -384,13 +394,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { attachments: input.attachments, }); - if (input.modelSelection.provider !== "codex") { - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Invalid model selection.", - }); - } - const generated = yield* runCodexJson({ operation: "generateThreadTitle", cwd: input.cwd, @@ -412,5 +415,3 @@ const makeCodexTextGeneration = Effect.gen(function* () { generateThreadTitle, } satisfies TextGenerationShape; }); - -export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts new file mode 100644 index 00000000000..c784530cd9b --- /dev/null +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -0,0 +1,272 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as path from "node:path"; +import * as os from "node:os"; +import { fileURLToPath } from "node:url"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vitest"; + +import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; +const decodeCursorSettings = Schema.decodeSync(CursorSettings); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeAcpAgentWrapper(dir: string, env: Record): string { + const binDir = path.join(dir, "bin"); + const agentPath = path.join(binDir, "agent"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + agentPath, + [ + "#!/bin/sh", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + 'if [ "$1" != "acp" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec bun ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + chmodSync(agentPath, 0o755); + return agentPath; +} + +function withFakeAcpAgent( + env: Record, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const agentPath = makeAcpAgentWrapper(tempDir, env); + const config = decodeCursorSettings({ binaryPath: agentPath }); + const textGeneration = yield* makeCursorTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +function waitForFileContent(path: string): Effect.Effect { + return Effect.gen(function* () { + const deadline = (yield* Clock.currentTimeMillis) + 5_000; + for (;;) { + const result = yield* Effect.exit(Effect.sync(() => readFileSync(path, "utf8"))); + if (Exit.isSuccess(result)) { + return result.value; + } + { + if ((yield* Clock.currentTimeMillis) >= deadline) { + return yield* Effect.die(result.cause); + } + } + yield* Effect.sleep(25); + } + }); +} + +it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { + it.effect("uses ACP model config options instead of raw CLI model ids", () => { + const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); + const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpAgent( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add generated commit message", + body: "- verify cursor acp model config path", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-text-generation", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), + }, + }); + + expect(generated.subject).toBe("Add generated commit message"); + expect(generated.body).toBe("- verify cursor acp model config path"); + + const requests = readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map( + (line) => JSON.parse(line) as { method?: string; params?: Record }, + ); + + expect( + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, + ).toMatchObject({ + _meta: { + parameterizedModelPicker: true, + }, + }); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === "gpt-5.4", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "reasoning" && + request.params?.value === "extra-high", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "context" && + request.params?.value === "1m", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "fast" && + request.params?.value === "true", + ), + ).toBe(true); + expect( + requests.find((request) => request.method === "session/prompt")?.params?.prompt, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Staged patch:"), + }), + ]), + ); + + rmSync(requestLogDir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("accepts json objects with extra assistant text around them", () => + withFakeAcpAgent( + { + T3_ACP_PROMPT_RESPONSE_TEXT: + 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-noisy-json", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.subject).toBe("Update README dummy comment with attribution and date"); + expect(generated.body).toBe(""); + }), + ), + ); + + it.effect("generates thread titles through Cursor ACP text generation", () => + withFakeAcpAgent( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: '"Trim reconnect spinner status after resume."', + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Fix the reconnect spinner after a resumed session.", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.title).toBe("Trim reconnect spinner status after resume."); + }), + ), + ); + + it.effect("closes the ACP child process after text generation completes", () => { + const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); + const exitLogPath = path.join(exitLogDir, "exit.log"); + + return withFakeAcpAgent( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Close runtime after generation", + body: "", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-runtime-close", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.subject).toBe("Close runtime after generation"); + + const exitLog = yield* waitForFileContent(exitLogPath); + expect(exitLog).toContain("exit:0"); + + rmSync(exitLogDir, { recursive: true, force: true }); + }), + ); + }); +}); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts new file mode 100644 index 00000000000..c4ef1af21d1 --- /dev/null +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -0,0 +1,278 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyCursorAcpModelSelection, + makeCursorAcpRuntime, +} from "../provider/acp/CursorAcpSupport.ts"; + +const CURSOR_TIMEOUT_MS = 180_000; + +function mapCursorAcpError( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + detail: string, + cause: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + +/** + * Build a Cursor text-generation closure bound to a specific `CursorSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runCursorJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeCursorAcpRuntime({ + cursorSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyCursorAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId, step }) => + mapCursorAcpError( + operation, + step === "set-config-option" + ? `Failed to set Cursor ACP config option "${configId}" for text generation.` + : "Failed to set Cursor ACP base model for text generation.", + cause, + ), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(CURSOR_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Cursor ACP request was cancelled." + : "Cursor Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CursorTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runCursorJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CursorTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runCursorJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CursorTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runCursorJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CursorTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runCursorJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts new file mode 100644 index 00000000000..8a5deacdb79 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -0,0 +1,349 @@ +import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as NetService from "@t3tools/shared/Net"; +import { beforeEach, expect } from "vitest"; + +import { ServerConfig } from "../config.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../provider/opencodeRuntime.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; + +const runtimeMock = { + state: { + startCalls: [] as string[], + promptUrls: [] as string[], + authHeaders: [] as Array, + closeCalls: [] as string[], + promptResult: undefined as + | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | undefined, + }, + reset() { + this.state.startCalls.length = 0; + this.state.promptUrls.length = 0; + this.state.authHeaders.length = 0; + this.state.closeCalls.length = 0; + this.state.promptResult = undefined; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ binaryPath }) => + Effect.gen(function* () { + const index = runtimeMock.state.startCalls.length + 1; + const url = `http://127.0.0.1:${4_300 + index}`; + runtimeMock.state.startCalls.push(binaryPath); + // The production runtime binds server lifetime to the caller's scope. + // Mirror that here so the closeCalls probe observes scope close. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + }), + ); + return { + url, + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: ({ serverUrl }) => + Effect.succeed({ + url: serverUrl ?? "http://127.0.0.1:4301", + exitCode: null, + external: Boolean(serverUrl), + }), + runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + ({ + session: { + create: async () => ({ data: { id: `${baseUrl}/session` } }), + prompt: async () => { + runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return ( + runtimeMock.state.promptResult ?? { + data: { + parts: [ + { + type: "text", + text: JSON.stringify({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }), + }, + ], + }, + } + ); + }, + }, + }) as unknown as ReturnType, + loadOpenCodeInventory: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", + cause: null, + }), + ), +}; + +const DEFAULT_TEST_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", +}; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +const OpenCodeTextGenerationTestLayer = Layer.succeed( + OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-test-", + }), + ), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), +); + +const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( + OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-existing-server-test-", + }), + ), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), +); + +const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", +}); +const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", +}); + +function withOpenCodeTextGeneration( + settings: OpenCodeSettings, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration(settings); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +beforeEach(() => { + runtimeMock.reset(); +}); + +const advanceIdleClock = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); + yield* Effect.yieldNow; +}); + +it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("starts a new server after the warm server idles out", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + yield* advanceIdleClock; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4302", + ]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { data: {} }; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode returned empty output."); + }), + ), + ); + + it.effect("parses JSON returned as plain text output", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', + }, + ], + }, + }; + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(result).toEqual({ + subject: "Tighten OpenCode parsing", + body: "Handle JSON text output locally.", + }); + }), + ), + ); + + it.effect("surfaces the upstream OpenCode structured-output error message", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + info: { + error: { + name: "StructuredOutputError", + data: { + message: "Model did not produce structured output", + retries: 2, + }, + }, + }, + }, + }; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("Model did not produce structured output"); + }), + ), + ); +}); + +it.layer(OpenCodeTextGenerationExistingServerTestLayer)( + "OpenCodeTextGeneration with configured server URL", + (it) => { + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + `Basic ${btoa("opencode:secret-password")}`, + ]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual([]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + }, +); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts new file mode 100644 index 00000000000..b865b2e5ef5 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -0,0 +1,467 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import { + TextGenerationError, + type ChatAttachment, + type ModelSelection, + type OpenCodeSettings, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { ServerConfig } from "../config.ts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + OpenCodeRuntime, + type OpenCodeServerConnection, + type OpenCodeServerProcess, + openCodeRuntimeErrorDetail, + parseOpenCodeModelSlug, + toOpenCodeFileParts, +} from "../provider/opencodeRuntime.ts"; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; + +function getOpenCodePromptErrorMessage(error: unknown): string | null { + if (!error || typeof error !== "object") { + return null; + } + + const message = + "data" in error && + error.data && + typeof error.data === "object" && + "message" in error.data && + typeof error.data.message === "string" + ? error.data.message.trim() + : ""; + if (message.length > 0) { + return message; + } + + if ("name" in error && typeof error.name === "string") { + const name = error.name.trim(); + return name.length > 0 ? name : null; + } + + return null; +} + +function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { + return (parts ?? []) + .flatMap((part) => { + if (!part || typeof part !== "object") { + return []; + } + if (!("type" in part) || part.type !== "text") { + return []; + } + if (!("text" in part) || typeof part.text !== "string") { + return []; + } + return [part.text]; + }) + .join("") + .trim(); +} + +interface SharedOpenCodeTextGenerationServerState { + server: OpenCodeServerProcess | null; + /** + * The scope that owns the shared server's lifetime. Closing this scope + * terminates the OpenCode child process and interrupts any fibers the + * runtime forked during startup. We don't hold a `close()` function on + * the server handle anymore — the scope is the only lifecycle handle. + */ + serverScope: Scope.Closeable | null; + binaryPath: string | null; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( + openCodeSettings: OpenCodeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime; + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const sharedServerMutex = yield* Semaphore.make(1); + const sharedServerState: SharedOpenCodeTextGenerationServerState = { + server: null, + serverScope: null, + binaryPath: null, + activeRequests: 0, + idleCloseFiber: null, + }; + + const closeSharedServer = Effect.fn("closeSharedServer")(function* () { + const scope = sharedServerState.serverScope; + sharedServerState.server = null; + sharedServerState.serverScope = null; + sharedServerState.binaryPath = null; + if (scope !== null) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + }); + + const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { + const idleCloseFiber = sharedServerState.idleCloseFiber; + sharedServerState.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + }); + + const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( + server: OpenCodeServerProcess, + ) { + yield* cancelIdleCloseFiber(); + const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( + Effect.andThen( + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { + return; + } + sharedServerState.idleCloseFiber = null; + yield* closeSharedServer(); + }), + ), + ), + Effect.forkIn(idleFiberScope), + ); + sharedServerState.idleCloseFiber = fiber; + }); + + const acquireSharedServer = (input: { + readonly binaryPath: string; + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + }) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + + const existingServer = sharedServerState.server; + if (existingServer !== null) { + if ( + sharedServerState.binaryPath !== input.binaryPath && + sharedServerState.activeRequests === 0 + ) { + yield* closeSharedServer(); + } else { + if (sharedServerState.binaryPath !== input.binaryPath) { + yield* Effect.logWarning( + "OpenCode shared server binary path mismatch: requested " + + input.binaryPath + + " but active server uses " + + sharedServerState.binaryPath + + "; reusing existing server because there are active requests", + ); + } + sharedServerState.activeRequests += 1; + return existingServer; + } + } + + // Create a fresh scope that owns this shared server. The runtime + // will attach its child-process and fiber finalizers to this scope; + // closing it kills the server and interrupts those fibers. + // + // The `Scope.make` / spawn / record-or-close transitions run inside + // `uninterruptibleMask` so an interrupt arriving between any two + // steps can't orphan the scope (and the child process attached to + // it) before we either close it on failure or hand ownership to + // `sharedServerState`. `restore` keeps the actual spawn + // interruptible; an interrupt during the spawn is captured by + // `Effect.exit` and drives us through the failure branch that + // closes the fresh scope. + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const serverScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + restore( + openCodeRuntime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + environment, + }) + .pipe( + Effect.provideService(Scope.Scope, serverScope), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + ), + ), + ), + ); + if (startedExit._tag === "Failure") { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(startedExit.cause); + } + + const server = startedExit.value; + sharedServerState.server = server; + sharedServerState.serverScope = serverScope; + sharedServerState.binaryPath = input.binaryPath; + sharedServerState.activeRequests = 1; + return server; + }), + ); + }), + ); + + const releaseSharedServer = (server: OpenCodeServerProcess) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server) { + return; + } + sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); + if (sharedServerState.activeRequests === 0) { + yield* scheduleIdleClose(server); + } + }), + ); + + // Module-level finalizer: on layer shutdown, cancel the idle close fiber + // and close the shared server scope. Consumers therefore cannot leak + // the shared OpenCode server by forgetting to call anything. + yield* Effect.addFinalizer(() => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + sharedServerState.activeRequests = 0; + yield* closeSharedServer(); + }), + ), + ); + + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }) { + const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + + const runAgainstServer = (server: Pick) => + Effect.tryPromise({ + try: async () => { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const session = await client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }); + if (!session.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue( + input.modelSelection, + "variant", + ); + + const result = await client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }); + const info = result.data?.info; + const errorMessage = getOpenCodePromptErrorMessage(info?.error); + if (errorMessage) { + throw new Error(errorMessage); + } + const rawText = getOpenCodeTextResponse(result.data?.parts); + if (rawText.length === 0) { + throw new Error("OpenCode returned empty output."); + } + return rawText; + }, + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + }); + + const rawOutput = + openCodeSettings.serverUrl.length > 0 + ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) + : yield* Effect.acquireUseRelease( + acquireSharedServer({ + binaryPath: openCodeSettings.binaryPath, + operation: input.operation, + }), + runAgainstServer, + releaseSharedServer, + ); + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "OpenCodeTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "OpenCodeTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "OpenCodeTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "OpenCodeTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts new file mode 100644 index 00000000000..2f518f1656d --- /dev/null +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -0,0 +1,120 @@ +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import { describe, expect } from "vitest"; + +import { ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; + +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { TextGenerationShape } from "./TextGeneration.ts"; + +import { makeTextGenerationFromRegistry } from "./TextGeneration.ts"; + +const makeStubTextGeneration = (overrides: Partial): TextGenerationShape => ({ + generateCommitMessage: () => + Effect.die("generateCommitMessage stub not configured for this test"), + generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), + generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), + generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + ...overrides, +}); + +const makeStubInstance = ( + instanceId: ProviderInstanceId, + textGeneration: TextGenerationShape, +): ProviderInstance => + ({ + instanceId, + driverKind: instanceId as unknown as ProviderInstance["driverKind"], + continuationIdentity: { + driverKind: instanceId as unknown as ProviderInstance["driverKind"], + continuationKey: `${instanceId}:test`, + }, + displayName: undefined, + enabled: true, + snapshot: {} as ProviderInstance["snapshot"], + adapter: {} as ProviderInstance["adapter"], + textGeneration, + }) satisfies ProviderInstance; + +const makeStubRegistry = ( + instances: ReadonlyArray, +): ProviderInstanceRegistryShape => { + const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); + return { + getInstance: (id) => Effect.succeed(byId.get(id)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + // Tests never drive changes through this stub; acquire a throwaway + // subscription on an unused PubSub so the shape is satisfied. + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; +}; + +describe("makeTextGenerationFromRegistry", () => { + it.effect("delegates to the matching instance's textGeneration closure", () => + Effect.gen(function* () { + const personalId = ProviderInstanceId.make("codex_personal"); + const personalCalls: string[] = []; + const personal = makeStubInstance( + personalId, + makeStubTextGeneration({ + generateBranchName: (input) => { + personalCalls.push(input.message); + return Effect.succeed({ branch: "personal-branch" }); + }, + }), + ); + + const workId = ProviderInstanceId.make("codex_work"); + const work = makeStubInstance( + workId, + makeStubTextGeneration({ + generateBranchName: () => Effect.succeed({ branch: "work-branch" }), + }), + ); + + const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); + + const result = yield* tg.generateBranchName({ + cwd: process.cwd(), + message: "Refactor the routing layer", + modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), + }); + + expect(result.branch).toBe("personal-branch"); + expect(personalCalls).toEqual(["Refactor the routing layer"]); + }), + ); + + it.effect("fails with TextGenerationError when the instance is unknown", () => + Effect.gen(function* () { + const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); + + const result = yield* tg + .generateBranchName({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection( + ProviderInstanceId.make("missing_instance"), + "gpt-5", + ), + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("TextGenerationError"); + expect(result.failure.operation).toBe("generateBranchName"); + expect(result.failure.detail).toContain("missing_instance"); + } + }), + ); +}); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts similarity index 55% rename from apps/server/src/git/Services/TextGeneration.ts rename to apps/server/src/textGeneration/TextGeneration.ts index 6062d552d95..36a23d509db 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,19 +1,16 @@ -/** - * TextGeneration - Effect service contract for AI-generated Git content. - * - * Generates commit messages and pull request titles/bodies from repository - * context prepared by Git services. - * - * @module TextGeneration - */ -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { TextGenerationError } from "@t3tools/contracts"; -import type { TextGenerationError } from "@t3tools/contracts"; +import { + ProviderInstanceRegistry, + type ProviderInstanceRegistryShape, +} from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -/** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; export interface CommitMessageGenerationInput { cwd: string; @@ -119,5 +116,58 @@ export interface TextGenerationShape { * TextGeneration - Service tag for commit and PR text generation. */ export class TextGeneration extends Context.Service()( - "t3/git/Services/TextGeneration", + "t3/text-generation/TextGeneration", ) {} + +type TextGenerationOp = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +const resolveInstance = ( + registry: ProviderInstanceRegistryShape, + operation: TextGenerationOp, + instanceId: ProviderInstanceId, +): Effect.Effect => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance + ? Effect.succeed(instance.textGeneration) + : Effect.fail( + new TextGenerationError({ + operation, + detail: `No provider instance registered for id '${instanceId}'.`, + }), + ), + ), + ); + +export const makeTextGenerationFromRegistry = ( + registry: ProviderInstanceRegistryShape, +): TextGenerationShape => ({ + generateCommitMessage: (input) => + resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), + ), + generatePrContent: (input) => + resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + ), + generateBranchName: (input) => + resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + ), + generateThreadTitle: (input) => + resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), + ), +}); + +export const layer = Layer.effect( + TextGeneration, + Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + return makeTextGenerationFromRegistry(registry); + }), +); diff --git a/apps/server/src/textGeneration/TextGenerationPolicy.ts b/apps/server/src/textGeneration/TextGenerationPolicy.ts new file mode 100644 index 00000000000..422927373d3 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPolicy.ts @@ -0,0 +1,19 @@ +import * as Schema from "effect/Schema"; + +export const TextGenerationPolicyKind = Schema.Literals([ + "default", + "conventional_commits", + "repo_conventions", + "custom", +]); +export type TextGenerationPolicyKind = typeof TextGenerationPolicyKind.Type; + +export const TextGenerationPolicy = Schema.Struct({ + kind: TextGenerationPolicyKind, + commitInstructions: Schema.optional(Schema.String), + changeRequestInstructions: Schema.optional(Schema.String), + branchInstructions: Schema.optional(Schema.String), + threadTitleInstructions: Schema.optional(Schema.String), + inferRepositoryConventions: Schema.Boolean, +}); +export type TextGenerationPolicy = typeof TextGenerationPolicy.Type; diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts new file mode 100644 index 00000000000..70955742148 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -0,0 +1,41 @@ +import type { TextGenerationPolicy, TextGenerationPolicyKind } from "./TextGenerationPolicy.ts"; + +export const defaultTextGenerationPolicy: TextGenerationPolicy = { + kind: "default", + inferRepositoryConventions: false, +}; + +export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = { + kind: "conventional_commits", + commitInstructions: + "Use Conventional Commits when generating commit subjects. Prefer the narrowest accurate type and include a scope only when it is obvious from the diff.", + changeRequestInstructions: + "Keep the change request title concise. Do not force Conventional Commit syntax into the title unless the repository already uses it.", + inferRepositoryConventions: false, +}; + +export const repositoryConventionsTextGenerationPolicy: TextGenerationPolicy = { + kind: "repo_conventions", + commitInstructions: + "Follow the repository's established commit message style when examples are available.", + changeRequestInstructions: + "Follow the repository's established change request title and body style when examples are available.", + inferRepositoryConventions: true, +}; + +export const customTextGenerationPolicy = ( + overrides: Omit, "kind">, +): TextGenerationPolicy => ({ + kind: "custom", + inferRepositoryConventions: false, + ...overrides, +}); + +export const textGenerationPresets: Record< + Exclude, + TextGenerationPolicy +> = { + default: defaultTextGenerationPolicy, + conventional_commits: conventionalCommitsTextGenerationPolicy, + repo_conventions: repositoryConventionsTextGenerationPolicy, +}; diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts similarity index 98% rename from apps/server/src/git/Prompts.test.ts rename to apps/server/src/textGeneration/TextGenerationPrompts.test.ts index d8d079c0cf3..25fed642270 100644 --- a/apps/server/src/git/Prompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -5,8 +5,8 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "./Prompts.ts"; -import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; +} from "./TextGenerationPrompts.ts"; +import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { TextGenerationError } from "@t3tools/contracts"; describe("buildCommitMessagePrompt", () => { diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts similarity index 86% rename from apps/server/src/git/Prompts.ts rename to apps/server/src/textGeneration/TextGenerationPrompts.ts index 4092358825c..6015e83b5d4 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -6,10 +6,16 @@ * * @module textGenerationPrompts */ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import type { ChatAttachment } from "@t3tools/contracts"; -import { limitSection } from "./Utils.ts"; +import { limitSection } from "./TextGenerationUtils.ts"; +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; + +function policyInstruction(instruction: string | undefined): ReadonlyArray { + const trimmed = instruction?.trim(); + return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; +} // --------------------------------------------------------------------------- // Commit message @@ -20,6 +26,7 @@ export interface CommitMessagePromptInput { stagedSummary: string; stagedPatch: string; includeBranch: boolean; + policy?: TextGenerationPolicy | undefined; } export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { @@ -37,6 +44,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- capture the primary user-visible or developer-visible change", + ...policyInstruction(input.policy?.commitInstructions), "", `Branch: ${input.branch ?? "(detached)"}`, "", @@ -77,6 +85,7 @@ export interface PrContentPromptInput { commitSummary: string; diffSummary: string; diffPatch: string; + policy?: TextGenerationPolicy | undefined; } export function buildPrContentPrompt(input: PrContentPromptInput) { @@ -88,6 +97,7 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { "- body must be markdown and include headings '## Summary' and '## Testing'", "- under Summary, provide short bullet points", "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ...policyInstruction(input.policy?.changeRequestInstructions), "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, @@ -117,6 +127,7 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { export interface BranchNamePromptInput { message: string; attachments?: ReadonlyArray | undefined; + policy?: TextGenerationPolicy | undefined; } interface PromptFromMessageInput { @@ -125,6 +136,7 @@ interface PromptFromMessageInput { rules: ReadonlyArray; message: string; attachments?: ReadonlyArray | undefined; + additionalInstructions?: string | undefined; } function buildPromptFromMessage(input: PromptFromMessageInput): string { @@ -140,6 +152,7 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { "", "User message:", limitSection(input.message, 8_000), + ...policyInstruction(input.additionalInstructions), ]; if (attachmentLines.length > 0) { promptSections.push( @@ -164,6 +177,7 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { ], message: input.message, attachments: input.attachments, + additionalInstructions: input.policy?.branchInstructions, }); const outputSchema = Schema.Struct({ branch: Schema.String, @@ -179,6 +193,7 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { export interface ThreadTitlePromptInput { message: string; attachments?: ReadonlyArray | undefined; + policy?: TextGenerationPolicy | undefined; } export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { @@ -193,6 +208,7 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { ], message: input.message, attachments: input.attachments, + additionalInstructions: input.policy?.threadTitleInstructions, }); const outputSchema = Schema.Struct({ title: Schema.String, diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts new file mode 100644 index 00000000000..a786f81b2c8 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -0,0 +1,112 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export function toJsonSchemaObject(schema: Schema.Top): unknown { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return { ...document.schema, $defs: document.definitions }; + } + return document.schema; +} + +/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ +export function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}\n\n[truncated]`; +} + +/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ +export function sanitizeCommitSubject(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +/** Normalise a raw PR title to a single line with a sensible fallback. */ +export function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ +export function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + + if (normalized.length <= 50) { + return normalized; + } + + return `${normalized.slice(0, 47).trimEnd()}...`; +} + +/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ +function cliLabel(cliName: string): string { + const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); + return `${capitalized} CLI (\`${cliName}\`)`; +} + +/** + * Normalize an unknown error from a CLI text generation process into a + * typed `TextGenerationError`. Parameterized by CLI name so both Codex + * and Claude (and future providers) can share the same logic. + */ +export function normalizeCliError( + cliName: string, + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (isTextGenerationError(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + error.message.includes(`Command not found: ${cliName}`) || + lower.includes(`spawn ${cliName}`) || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: `${cliLabel(cliName)} is required but not available on PATH.`, + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts new file mode 100644 index 00000000000..70bb8655ea1 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -0,0 +1,110 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { assert, it } from "@effect/vitest"; + +import { GitCommandError } from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; +import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-contract-", +}); +const GitContractLayer = Layer.mergeAll(GitVcsDriver.vcsLayer, GitVcsDriver.layer).pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const runGit = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.execute({ + operation: "GitVcsDriver.contract.git", + cwd, + args, + timeoutMs: 10_000, + }); + }); + +type GitContractError = GitCommandError | PlatformError.PlatformError; + +runVcsDriverContractSuite({ + name: "Git", + kind: "git", + layer: GitContractLayer, + fixture: { + createRepo: (cwd) => + Effect.gen(function* () { + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + }), + writeFile: (cwd, relativePath, contents) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fileSystem.writeFileString(absolutePath, contents); + }), + trackFile: (cwd, relativePath) => runGit(cwd, ["add", relativePath]), + commit: (cwd, message) => runGit(cwd, ["commit", "-m", message]), + ignorePath: (cwd, pattern) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`); + }), + }, +}); + +it.effect("GitVcsDriver forwards execute env to the VCS process", () => { + let observedEnv: NodeJS.ProcessEnv | undefined; + let observedAppendTruncationMarker: boolean | undefined; + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + yield* driver.execute({ + operation: "GitVcsDriver.test.env", + cwd: "/repo", + args: ["status"], + env: { + GIT_INDEX_FILE: "/tmp/t3-index", + }, + appendTruncationMarker: true, + }); + + assert.deepStrictEqual(observedEnv, { + GIT_INDEX_FILE: "/tmp/t3-index", + }); + assert.strictEqual(observedAppendTruncationMarker, true); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + observedEnv = input.env; + observedAppendTruncationMarker = input.appendTruncationMarker; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), + ), + ); +}); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts new file mode 100644 index 00000000000..c07aeaa09f0 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -0,0 +1,827 @@ +import { randomUUID } from "node:crypto"; + +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +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 { ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitCommandError, + VcsProcessExitError, + type VcsSwitchRefInput, + type VcsSwitchRefResult, + type VcsCreateRefInput, + type VcsCreateRefResult, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsInitInput, + type VcsListRefsInput, + type VcsListRefsResult, + type VcsPullResult, + type VcsRemoveWorktreeInput, + type VcsStatusInput, + type VcsStatusResult, +} from "@t3tools/contracts"; +import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; +import * as VcsDriver from "./VcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +export interface ExecuteGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly appendTruncationMarker?: boolean; + readonly progress?: ExecuteGitProgress; +} + +export interface ExecuteGitResult { + readonly exitCode: ChildProcessSpawner.ExitCode; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface GitStatusDetails { + isRepo: boolean; + sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; + hasOriginRemote: boolean; + isDefaultBranch: boolean; + branch: string | null; + upstreamRef: string | null; + hasWorkingTreeChanges: boolean; + workingTree: VcsStatusResult["workingTree"]; + hasUpstream: boolean; + aheadCount: number; + behindCount: number; + aheadOfDefaultCount: number; +} + +export interface GitPreparedCommitContext { + stagedSummary: string; + stagedPatch: string; +} + +export interface ExecuteGitProgress { + readonly onStdoutLine?: (line: string) => Effect.Effect; + readonly onStderrLine?: (line: string) => Effect.Effect; + readonly onHookStarted?: (hookName: string) => Effect.Effect; + readonly onHookFinished?: (input: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => Effect.Effect; +} + +export interface GitCommitProgress { + readonly onOutputLine?: (input: { + stream: "stdout" | "stderr"; + text: string; + }) => Effect.Effect; + readonly onHookStarted?: (hookName: string) => Effect.Effect; + readonly onHookFinished?: (input: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => Effect.Effect; +} + +export interface GitCommitOptions { + readonly timeoutMs?: number; + readonly progress?: GitCommitProgress; +} + +export interface GitPushResult { + status: "pushed" | "skipped_up_to_date"; + branch: string; + upstreamBranch?: string | undefined; + setUpstream?: boolean | undefined; +} + +export interface GitRangeContext { + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +export interface GitRenameBranchInput { + cwd: string; + oldBranch: string; + newBranch: string; +} + +export interface GitRenameBranchResult { + branch: string; +} + +export interface GitFetchPullRequestBranchInput { + cwd: string; + prNumber: number; + branch: string; +} + +export interface GitEnsureRemoteInput { + cwd: string; + preferredName: string; + url: string; +} + +export interface GitFetchRemoteBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; + localBranch: string; +} + +export interface GitFetchRemoteTrackingBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; +} + +export interface GitSetBranchUpstreamInput { + cwd: string; + branch: string; + remoteName: string; + remoteBranch: string; +} + +export interface GitVcsDriverShape { + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; + readonly statusDetails: (cwd: string) => Effect.Effect; + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + readonly commit: ( + cwd: string, + subject: string, + body: string, + options?: GitCommitOptions, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; + readonly pushCurrentBranch: ( + cwd: string, + fallbackBranch: string | null, + options?: { readonly remoteName?: string | null }, + ) => Effect.Effect; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + readonly fetchRemoteTrackingBranch: ( + input: GitFetchRemoteTrackingBranchInput, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly initRepo: (input: VcsInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; +} + +export class GitVcsDriver extends Context.Service()( + "t3/vcs/GitVcsDriver", +) {} + +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; +const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; +const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", +] as const; + +const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { + const now = yield* DateTime.now; + return { + source: "live-local" as const, + observedAt: now, + expiresAt: Option.none(), + }; +}); + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { + const chunks: string[][] = []; + let chunk: string[] = []; + let chunkBytes = 0; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + } + + if (chunk.length > 0) { + chunks.push(chunk); + } + + return chunks; +} + +function parseGitRemoteVerboseOutput( + output: string, +): Map { + const remotes = new Map(); + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) { + continue; + } + + const name = match[1]; + const url = match[2]; + const direction = match[3]; + if (!name || !url || !direction) { + continue; + } + const remote = remotes.get(name) ?? {}; + if (direction === "fetch") { + remote.url = url; + } else { + remote.pushUrl = url; + } + remotes.set(name, remote); + } + return remotes; +} + +const gitCommand = ( + process: VcsProcess.VcsProcessShape, + operation: string, + cwd: string, + args: ReadonlyArray, + options?: { + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly appendTruncationMarker?: boolean; + }, +) => + process.run({ + operation, + command: "git", + args: ["-C", cwd, ...args], + cwd, + spawnCwd: globalThis.process.cwd(), + ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.allowNonZeroExit !== undefined + ? { allowNonZeroExit: options.allowNonZeroExit } + : {}), + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options?.appendTruncationMarker !== undefined + ? { appendTruncationMarker: options.appendTruncationMarker } + : {}), + }); + +export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcsProcess = yield* VcsProcess.VcsProcess; + const capabilities = { + kind: "git" as const, + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native" as const, + }; + + const isInsideWorkTree: VcsDriver.VcsDriverShape["isInsideWorkTree"] = (cwd) => + gitCommand( + vcsProcess, + "GitVcsDriver.isInsideWorkTree", + cwd, + ["rev-parse", "--is-inside-work-tree"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }, + ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); + + const execute: VcsDriver.VcsDriverShape["execute"] = (input) => + gitCommand(vcsProcess, input.operation, input.cwd, input.args, { + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + ...(input.appendTruncationMarker !== undefined + ? { appendTruncationMarker: input.appendTruncationMarker } + : {}), + }); + + const detectRepository: VcsDriver.VcsDriverShape["detectRepository"] = Effect.fn( + "detectRepository", + )(function* (cwd) { + if (!(yield* isInsideWorkTree(cwd))) { + return null; + } + + const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand( + vcsProcess, + "GitVcsDriver.detectRepository.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + ).pipe(Effect.catch(() => Effect.succeed(null))); + + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + }); + + const listWorkspaceFiles: VcsDriver.VcsDriverShape["listWorkspaceFiles"] = (cwd) => + gitCommand( + vcsProcess, + "GitVcsDriver.listWorkspaceFiles", + cwd, + [ + ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ], + { + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.gen(function* () { + const freshness = yield* nowFreshness(); + return { + paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + truncated: result.stdoutTruncated, + freshness, + }; + }) + : Effect.fail( + new VcsProcessExitError({ + operation: "GitVcsDriver.listWorkspaceFiles", + command: "git ls-files", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git ls-files failed", + }), + ), + ), + ); + + const listRemotes: VcsDriver.VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")( + function* (cwd) { + const result = yield* gitCommand( + vcsProcess, + "GitVcsDriver.listRemotes", + cwd, + ["remote", "-v"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }, + ); + + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.listRemotes", + command: "git remote -v", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git remote -v failed", + }); + } + + const parsed = parseGitRemoteVerboseOutput(result.stdout); + const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + if (!remote.url) { + return []; + } + return [ + { + name, + url: remote.url, + pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), + isPrimary: name === "origin", + }, + ]; + }); + + return { + remotes, + freshness: yield* nowFreshness(), + }; + }, + ); + + const filterIgnoredPaths: VcsDriver.VcsDriverShape["filterIgnoredPaths"] = Effect.fn( + "filterIgnoredPaths", + )(function* (cwd, relativePaths) { + if (relativePaths.length === 0) { + return relativePaths; + } + + const ignoredPaths = new Set(); + const chunks = chunkPathsForGitCheckIgnore(relativePaths); + + for (const chunk of chunks) { + const result = yield* gitCommand( + vcsProcess, + "GitVcsDriver.filterIgnoredPaths", + cwd, + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.filterIgnoredPaths", + command: "git check-ignore", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git check-ignore failed", + }); + } + + for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + ignoredPaths.add(ignoredPath); + } + } + + if (ignoredPaths.size === 0) { + return relativePaths; + } + + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }); + + const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => + gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { + timeoutMs: 10_000, + maxOutputBytes: 64 * 1024, + }).pipe(Effect.asVoid); + + const resolveHeadCommit = (cwd: string) => + execute({ + operation: "GitVcsDriver.checkpoints.resolveHeadCommit", + cwd, + args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + }), + ); + + const hasHeadCommit = (cwd: string) => + execute({ + operation: "GitVcsDriver.checkpoints.hasHeadCommit", + cwd, + args: ["rev-parse", "--verify", "HEAD"], + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.exitCode === 0)); + + const resolveCheckpointCommit = (cwd: string, checkpointRef: string) => + execute({ + operation: "GitVcsDriver.checkpoints.resolveCheckpointCommit", + cwd, + args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + }), + ); + + const resolveGitCommonDir = (cwd: string) => + Effect.gen(function* () { + const result = yield* execute({ + operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], + }); + const gitCommonDir = result.stdout.trim(); + return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + }); + + const checkpoints: VcsDriver.VcsCheckpointOps = { + captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { + const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; + const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${randomUUID()}`); + const commitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_INDEX_FILE: tempIndexPath, + GIT_AUTHOR_NAME: "T3 Code", + GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", + GIT_COMMITTER_NAME: "T3 Code", + GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", + }; + + const cleanupTempIndex = fileSystem + .remove(tempIndexPath, { force: true }) + .pipe(Effect.ignore); + + yield* Effect.gen(function* () { + const headExists = yield* hasHeadCommit(input.cwd); + if (headExists) { + yield* execute({ + operation, + cwd: input.cwd, + args: ["read-tree", "HEAD"], + env: commitEnv, + }); + } + + yield* execute({ + operation, + cwd: input.cwd, + args: ["add", "-A", "--", "."], + env: commitEnv, + }); + + const writeTreeResult = yield* execute({ + operation, + cwd: input.cwd, + args: ["write-tree"], + env: commitEnv, + }); + const treeOid = writeTreeResult.stdout.trim(); + if (treeOid.length === 0) { + return yield* new VcsProcessExitError({ + operation, + command: "git write-tree", + cwd: input.cwd, + exitCode: 0, + detail: "git write-tree returned an empty tree oid.", + }); + } + + const message = `t3 checkpoint ref=${input.checkpointRef}`; + const commitTreeResult = yield* execute({ + operation, + cwd: input.cwd, + args: ["commit-tree", treeOid, "-m", message], + env: commitEnv, + }); + const commitOid = commitTreeResult.stdout.trim(); + if (commitOid.length === 0) { + return yield* new VcsProcessExitError({ + operation, + command: "git commit-tree", + cwd: input.cwd, + exitCode: 0, + detail: "git commit-tree returned an empty commit oid.", + }); + } + + yield* execute({ + operation, + cwd: input.cwd, + args: ["update-ref", input.checkpointRef, commitOid], + }); + }).pipe(Effect.ensuring(cleanupTempIndex)); + }), + + hasCheckpointRef: (input) => + resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( + Effect.map((commit) => commit !== null), + ), + + restoreCheckpoint: Effect.fn("GitVcsDriver.checkpoints.restoreCheckpoint")(function* (input) { + const operation = "GitVcsDriver.checkpoints.restoreCheckpoint"; + + let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); + + if (!commitOid && input.fallbackToHead === true) { + commitOid = yield* resolveHeadCommit(input.cwd); + } + + if (!commitOid) { + return false; + } + + yield* execute({ + operation, + cwd: input.cwd, + args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], + }); + yield* execute({ + operation, + cwd: input.cwd, + args: ["clean", "-fd", "--", "."], + }); + + const headExists = yield* hasHeadCommit(input.cwd); + if (headExists) { + yield* execute({ + operation, + cwd: input.cwd, + args: ["reset", "--quiet", "--", "."], + }); + } + + return true; + }), + + diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { + const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; + yield* Effect.annotateCurrentSpan({ + "checkpoint.cwd": input.cwd, + "checkpoint.from_ref": input.fromCheckpointRef, + "checkpoint.to_ref": input.toCheckpointRef, + "checkpoint.ignore_whitespace": input.ignoreWhitespace, + "checkpoint.fallback_from_to_head": input.fallbackFromToHead, + }); + + let fromRevision: string = input.fromCheckpointRef; + if (input.fallbackFromToHead === true) { + const resolvedFromCommit = yield* resolveCheckpointCommit( + input.cwd, + input.fromCheckpointRef, + ); + if (resolvedFromCommit) { + fromRevision = resolvedFromCommit; + } else { + const headCommit = yield* resolveHeadCommit(input.cwd); + if (!headCommit) { + return yield* new VcsProcessExitError({ + operation, + command: "git diff", + cwd: input.cwd, + exitCode: 1, + detail: "Checkpoint ref is unavailable for diff operation.", + }); + } + fromRevision = headCommit; + } + } + + const result = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + `${fromRevision}^{commit}`, + `${input.toCheckpointRef}^{commit}`, + ], + allowNonZeroExit: true, + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + }); + + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation, + command: "git diff", + cwd: input.cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "Checkpoint ref is unavailable for diff operation.", + }); + } + + return result.stdout; + }), + + deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( + function* (input) { + yield* Effect.forEach( + input.checkpointRefs, + (checkpointRef) => + execute({ + operation: "GitVcsDriver.checkpoints.deleteCheckpointRefs", + cwd: input.cwd, + args: ["update-ref", "-d", checkpointRef], + allowNonZeroExit: true, + }), + { discard: true }, + ); + }, + ), + }; + + return VcsDriver.VcsDriver.of({ + capabilities, + execute, + checkpoints, + detectRepository, + isInsideWorkTree, + listWorkspaceFiles, + listRemotes, + filterIgnoredPaths, + initRepository, + }); +}); + +export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { + const driver = yield* makeVcsDriverShape(); + return VcsDriver.VcsDriver.of(driver); +}); + +export const make = Effect.fn("makeGitVcsDriverService")(function* () { + const git = yield* GitVcsDriverCore.makeGitVcsDriverCore(); + return GitVcsDriver.of(git); +}); + +export const vcsLayer = Layer.effect(VcsDriver.VcsDriver, makeVcsDriver()); +export const layer = Layer.effect(GitVcsDriver, make()); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts new file mode 100644 index 00000000000..00f40a69aa7 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -0,0 +1,429 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it, describe } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +import { GitCommandError } from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-driver-test-", +}); +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTmpDir = ( + prefix = "git-vcs-driver-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + cwd: string, + relativePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const filePath = pathService.join(cwd, relativePath); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, + env?: NodeJS.ProcessEnv, +): Effect.Effect => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + { readonly initialBranch: string }, + GitCommandError | PlatformError.PlatformError, + GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = yield* git(cwd, ["branch", "--show-current"]); + return { initialBranch }; + }); + +it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("repository status", () => { + it.effect("reports non-repository directories without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("reports refName and dirty state for a repository", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasWorkingTreeChanges, true); + assert.include( + status.workingTree.files.map((file) => file.path), + "feature.ts", + ); + }), + ); + + it.effect("reports default-branch delta separately from upstream delta", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/synced"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + yield* git(cwd, ["push", "-u", "origin", "feature/synced"]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.hasUpstream, true); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + }), + ); + + it.effect("disables SSH askpass for background upstream status fetches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sshLogPath = pathService.join(tempDir, "ssh-env.txt"); + const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh"); + const previousGitSsh = process.env.GIT_SSH; + const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE; + const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG; + + yield* fileSystem.writeFileString( + sshWrapperPath, + [ + "#!/bin/sh", + 'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', + "exit 1", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(sshWrapperPath, 0o755); + yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); + yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]); + yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]); + + yield* Effect.gen(function* () { + process.env.GIT_SSH = sshWrapperPath; + process.env.SSH_ASKPASS_REQUIRE = "force"; + process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousGitSsh === undefined) { + delete process.env.GIT_SSH; + } else { + process.env.GIT_SSH = previousGitSsh; + } + if (previousAskpassRequire === undefined) { + delete process.env.SSH_ASKPASS_REQUIRE; + } else { + process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire; + } + if (previousAskpassLog === undefined) { + delete process.env.T3_TEST_SSH_ASKPASS_LOG; + } else { + process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog; + } + }), + ), + ); + }), + ); + + it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/no-upstream"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 1); + assert.equal(status.behindCount, 0); + assert.equal(status.aheadOfDefaultCount, 1); + }), + ); + }); + + describe("refName operations", () => { + it.effect("creates, checks out, renames, and lists refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createRef({ cwd, refName: "feature/original" }); + const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); + assert.equal(switchRef.refName, "feature/original"); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "feature/original", + newBranch: "feature/renamed", + }); + assert.equal(renamed.branch, "feature/renamed"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); + + const refs = yield* driver.listRefs({ cwd }); + assert.equal( + refs.refs.find((refName) => refName.name === "feature/renamed")?.current, + true, + ); + }), + ); + + it.effect("returns the existing refName when rename source and target match", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const current = yield* git(cwd, ["branch", "--show-current"]); + const result = yield* driver.renameBranch({ + cwd, + oldBranch: current, + newBranch: current, + }); + + assert.equal(result.branch, current); + }), + ); + }); + + describe("worktree operations", () => { + it.effect("creates and removes a worktree for a new refName", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "feature-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/worktree", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(created.worktree.refName, "feature/worktree"); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); + + yield* driver.removeWorktree({ cwd, path: worktreePath }); + const fileSystem = yield* FileSystem.FileSystem; + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + }); + + describe("commit context", () => { + it.effect("stages selected files and commits only those files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "a.txt", "a\n"); + yield* writeTextFile(cwd, "b.txt", "b\n"); + + const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); + assert.include(context?.stagedSummary ?? "", "a.txt"); + assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + + const commit = yield* driver.commit(cwd, "Add a", ""); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? b.txt"); + assert.notInclude(status, "a.txt"); + }), + ); + }); + + describe("remote operations", () => { + it.effect("pushes with upstream setup and skips when already up to date", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ + cwd, + refName: "feature/push", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "feature/push", + }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/push", + setUpstream: true, + }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/push", + ); + + const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(skipped, { + status: "skipped_up_to_date", + branch: "feature/push", + }); + }), + ); + + it.effect( + "pushes upstream branches to the remote branch name, not the upstream shorthand", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add upstream update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin/main", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), + "Add upstream update", + ); + const badBranch = yield* driver.execute({ + operation: "GitVcsDriver.test.showBadRemoteBranch", + cwd: remote, + args: ["show-ref", "--verify", "--quiet", "refs/heads/origin/main"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(badBranch.exitCode, 0); + }), + ); + + it.effect("pushes to the requested remote instead of the primary remote", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const originRemote = yield* makeTmpDir("git-origin-remote-"); + const publishRemote = yield* makeTmpDir("git-publish-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(originRemote, ["init", "--bare"]); + yield* git(publishRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", originRemote]); + yield* git(cwd, ["remote", "add", "origin-1", publishRemote]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null, { remoteName: "origin-1" }); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin-1/main", + setUpstream: true, + }); + assert.equal( + yield* git(publishRemote, ["log", "-1", "--pretty=%s", "main"]), + "initial commit", + ); + const originMain = yield* driver.execute({ + operation: "GitVcsDriver.test.originMainMissing", + cwd: originRemote, + args: ["show-ref", "--verify", "--quiet", "refs/heads/main"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(originMain.exitCode, 0); + }), + ); + }); +}); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts similarity index 57% rename from apps/server/src/git/Layers/GitCore.ts rename to apps/server/src/vcs/GitVcsDriverCore.ts index 3e9df316f1e..19f12862dad 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,43 +1,34 @@ -import { - Cache, - Data, - Duration, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - PlatformError, - Ref, - Result, - Schema, - Scope, - Semaphore, - Stream, -} from "effect"; +import * as Cache from "effect/Cache"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type GitBranch } from "@t3tools/contracts"; +import { GitCommandError, type VcsRef } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; -import { compactTraceAttributes } from "../../observability/Attributes.ts"; -import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts"; -import { - GitCore, - type ExecuteGitProgress, - type GitCommitOptions, - type GitCoreShape, - type GitStatusDetails, - type ExecuteGitInput, - type ExecuteGitResult, -} from "../Services/GitCore.ts"; +import { compactTraceAttributes } from "@t3tools/shared/observability"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; import { parseRemoteNames, parseRemoteNamesInGitOrder, parseRemoteRefWithRemoteNames, -} from "../remoteRefs.ts"; -import { ServerConfig } from "../../config.ts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +} from "../git/remoteRefs.ts"; +import { ServerConfig } from "../config.ts"; +const isGitCommandError = Schema.is(GitCommandError); const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -46,21 +37,16 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; -const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; -const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", -] as const; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ + SSH_ASKPASS_REQUIRE: "never", +} satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; -const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, isDefaultBranch: false, @@ -71,6 +57,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ hasUpstream: false, aheadCount: 0, behindCount: 0, + aheadOfDefaultCount: 0, }); type TraceTailState = { @@ -88,9 +75,10 @@ interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined; + env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; - truncateOutputAtMaxBytes?: boolean | undefined; - progress?: ExecuteGitProgress | undefined; + appendTruncationMarker?: boolean | undefined; + progress?: GitVcsDriver.ExecuteGitProgress | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { @@ -126,47 +114,6 @@ function parseNumstatEntries( return entries; } -function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { - const parts = input.split("\0"); - if (parts.length === 0) return []; - - if (truncated && parts[parts.length - 1]?.length) { - parts.pop(); - } - - return parts.filter((value) => value.length > 0); -} - -function chunkPathsForGitCheckIgnore(relativePaths: readonly string[]): string[][] { - const chunks: string[][] = []; - let chunk: string[] = []; - let chunkBytes = 0; - - for (const relativePath of relativePaths) { - const relativePathBytes = Buffer.byteLength(relativePath) + 1; - if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - } - - chunk.push(relativePath); - chunkBytes += relativePathBytes; - - if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - } - } - - if (chunk.length > 0) { - chunks.push(chunk); - } - - return chunks; -} - function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); @@ -205,34 +152,34 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul } function filterBranchesForListQuery( - branches: ReadonlyArray, + refs: ReadonlyArray, query?: string, -): ReadonlyArray { +): ReadonlyArray { if (!query) { - return branches; + return refs; } const normalizedQuery = query.toLowerCase(); - return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); + return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); } function paginateBranches(input: { - branches: ReadonlyArray; + refs: ReadonlyArray; cursor?: number | undefined; limit?: number | undefined; }): { - branches: ReadonlyArray; + refs: ReadonlyArray; nextCursor: number | null; totalCount: number; } { const cursor = input.cursor ?? 0; const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; - const totalCount = input.branches.length; - const branches = input.branches.slice(cursor, cursor + limit); - const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null; + const totalCount = input.refs.length; + const refs = input.refs.slice(cursor, cursor + limit); + const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; return { - branches, + refs, nextCursor, totalCount, }; @@ -273,7 +220,7 @@ function parseRemoteFetchUrls(stdout: string): Map { function parseUpstreamRefWithRemoteNames( upstreamRef: string, remoteNames: ReadonlyArray, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { +): { upstreamRef: string; remoteName: string; branchName: string } | null { const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); if (!parsed) { return null; @@ -282,28 +229,28 @@ function parseUpstreamRefWithRemoteNames( return { upstreamRef, remoteName: parsed.remoteName, - upstreamBranch: parsed.branchName, + branchName: parsed.branchName, }; } function parseUpstreamRefByFirstSeparator( upstreamRef: string, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { +): { upstreamRef: string; remoteName: string; branchName: string } | null { const separatorIndex = upstreamRef.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { return null; } const remoteName = upstreamRef.slice(0, separatorIndex).trim(); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim(); - if (remoteName.length === 0 || upstreamBranch.length === 0) { + const branchName = upstreamRef.slice(separatorIndex + 1).trim(); + if (remoteName.length === 0 || branchName.length === 0) { return null; } return { upstreamRef, remoteName, - upstreamBranch, + branchName, }; } @@ -315,11 +262,11 @@ function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): } const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); const branchName = branchNameRaw?.trim() ?? ""; - const upstreamBranch = upstreamBranchRaw.trim(); - if (branchName.length === 0 || upstreamBranch.length === 0) { + const candidateUpstreamRef = upstreamBranchRaw.trim(); + if (branchName.length === 0 || candidateUpstreamRef.length === 0) { continue; } - if (upstreamBranch === upstreamRef) { + if (candidateUpstreamRef === upstreamRef) { return branchName; } } @@ -346,8 +293,8 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): if (!trimmed.startsWith(prefix)) { return null; } - const branch = trimmed.slice(prefix.length).trim(); - return branch.length > 0 ? branch : null; + const refName = trimmed.slice(prefix.length).trim(); + return refName.length > 0 ? refName : null; } function createGitCommandError( @@ -381,11 +328,11 @@ function isMissingGitCwdError(error: GitCommandError): boolean { } function toGitCommandError( - input: Pick, + input: Pick, detail: string, ) { return (cause: unknown) => - Schema.is(GitCommandError)(cause) + isGitCommandError(cause) ? cause : new GitCommandError({ operation: input.operation, @@ -401,17 +348,18 @@ interface Trace2Monitor { readonly flush: Effect.Effect; } -const nowUnixNano = (): bigint => BigInt(Date.now()) * 1_000_000n; +const nowUnixNano = DateTime.now.pipe( + Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n), +); const addCurrentSpanEvent = (name: string, attributes: Record) => - Effect.currentSpan.pipe( - Effect.tap((span) => - Effect.sync(() => { - span.event(name, nowUnixNano(), compactTraceAttributes(attributes)); - }), - ), - Effect.catch(() => Effect.void), - ); + Effect.gen(function* () { + const span = yield* Effect.currentSpan; + const timestamp = yield* nowUnixNano; + yield* Effect.sync(() => { + span.event(name, timestamp, compactTraceAttributes(attributes)); + }); + }).pipe(Effect.catch(() => Effect.void)); function trace2ChildKey(record: Record): string | null { const childId = record.child_id; @@ -425,8 +373,8 @@ function trace2ChildKey(record: Record): string | null { const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( - input: Pick, - progress: ExecuteGitProgress | undefined, + input: Pick, + progress: GitVcsDriver.ExecuteGitProgress | undefined, ): Effect.fn.Return< Trace2Monitor, PlatformError.PlatformError, @@ -460,7 +408,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( - `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, + `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, traceRecord.failure, ); return; @@ -484,7 +432,8 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( } if (event === "child_start") { - hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); + const now = yield* DateTime.now; + hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); yield* addCurrentSpanEvent("git.hook.started", { hookName, }); @@ -496,9 +445,12 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.code; + const code = traceRecord.success.exitCode; const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; - const durationMs = started ? Math.max(0, Date.now() - started.startedAtMs) : null; + const now = yield* DateTime.now; + const durationMs = started + ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs) + : null; yield* addCurrentSpanEvent("git.hook.finished", { hookName: started?.hookName ?? hookName, exitCode, @@ -579,11 +531,11 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); -const collectOutput = Effect.fn("collectOutput")(function* ( - input: Pick, +const collectOutput = Effect.fnUntraced(function* ( + input: Pick, stream: Stream.Stream, maxOutputBytes: number, - truncateOutputAtMaxBytes: boolean, + appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { const decoder = new TextDecoder(); @@ -592,7 +544,7 @@ const collectOutput = Effect.fn("collectOutput")(function* ( let lineBuffer = ""; let truncated = false; - const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) { + const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { let newlineIndex = lineBuffer.indexOf("\n"); while (newlineIndex >= 0) { const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); @@ -612,12 +564,12 @@ const collectOutput = Effect.fn("collectOutput")(function* ( } }); - const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) { - if (truncateOutputAtMaxBytes && truncated) { + const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { + if (appendTruncationMarker && truncated) { return; } const nextBytes = bytes + chunk.byteLength; - if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { + if (!appendTruncationMarker && nextBytes > maxOutputBytes) { return yield* new GitCommandError({ operation: input.operation, command: quoteGitCommand(input.args), @@ -627,11 +579,11 @@ const collectOutput = Effect.fn("collectOutput")(function* ( } const chunkToDecode = - truncateOutputAtMaxBytes && nextBytes > maxOutputBytes + appendTruncationMarker && nextBytes > maxOutputBytes ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) : chunk; bytes += chunkToDecode.byteLength; - truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; + truncated = appendTruncationMarker && nextBytes > maxOutputBytes; const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); text += decoded; @@ -653,27 +605,21 @@ const collectOutput = Effect.fn("collectOutput")(function* ( }; }); -export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { - executeOverride?: GitCoreShape["execute"]; -}) { +export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig; - let executeRaw: GitCoreShape["execute"]; - - if (options?.executeOverride) { - executeRaw = options.executeOverride; - } else { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - executeRaw = Effect.fnUntraced(function* (input) { + const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced( + function* (input) { const commandInput = { ...input, args: [...input.args], } as const; const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; + const appendTruncationMarker = input.appendTruncationMarker ?? false; const runGitCommand = Effect.fn("runGitCommand")(function* () { const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( @@ -700,18 +646,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { commandInput, child.stdout, maxOutputBytes, - truncateOutputAtMaxBytes, + appendTruncationMarker, input.progress?.onStdoutLine, ), collectOutput( commandInput, child.stderr, maxOutputBytes, - truncateOutputAtMaxBytes, + appendTruncationMarker, input.progress?.onStderrLine, ), child.exitCode.pipe( - Effect.map((value) => Number(value)), Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), ), input.stdin === undefined @@ -738,12 +683,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } return { - code: exitCode, + exitCode, stdout: stdout.text, stderr: stderr.text, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, - } satisfies ExecuteGitResult; + } satisfies GitVcsDriver.ExecuteGitResult; }); return yield* runGitCommand().pipe( @@ -764,10 +709,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }), ), ); - }); - } + }, + ); - const execute: GitCoreShape["execute"] = (input) => + const execute: GitVcsDriver.GitVcsDriverShape["execute"] = (input) => executeRaw(input).pipe( withMetrics({ counter: gitCommandsTotal, @@ -791,22 +736,23 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, - ): Effect.Effect => + ): Effect.Effect => execute({ operation, cwd, args, ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), - ...(options.truncateOutputAtMaxBytes !== undefined - ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } + ...(options.appendTruncationMarker !== undefined + ? { appendTruncationMarker: options.appendTruncationMarker } : {}), ...(options.progress ? { progress: options.progress } : {}), }).pipe( Effect.flatMap((result) => { - if (options.allowNonZeroExit || result.code === 0) { + if (options.allowNonZeroExit || result.exitCode === 0) { return Effect.succeed(result); } const stderr = result.stderr.trim(); @@ -823,7 +769,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { operation, cwd, args, - `${commandLabel(args)} failed: code=${result.code ?? "null"}`, + `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`, ), ); }), @@ -859,16 +805,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ), ); - const branchExists = (cwd: string, branch: string): Effect.Effect => + const branchExists = (cwd: string, refName: string): Effect.Effect => executeGit( - "GitCore.branchExists", + "GitVcsDriver.branchExists", cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], { allowNonZeroExit: true, timeoutMs: 5_000, }, - ).pipe(Effect.map((result) => result.code === 0)); + ).pipe(Effect.map((result) => result.exitCode === 0)); const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( cwd: string, @@ -888,7 +834,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } return yield* createGitCommandError( - "GitCore.renameBranch", + "GitVcsDriver.renameBranch", cwd, ["branch", "-m", "--", desiredBranch], `Could not find an available branch name for '${desiredBranch}'.`, @@ -897,7 +843,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { const upstreamRef = yield* runGitStdout( - "GitCore.resolveCurrentUpstream", + "GitVcsDriver.resolveCurrentUpstream", cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], true, @@ -907,7 +853,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return null; } - const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNames), Effect.catch(() => Effect.succeed>([])), ); @@ -924,18 +870,19 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return executeGit( - "GitCore.fetchRemoteForStatus", + "GitVcsDriver.fetchRemoteForStatus", fetchCwd, ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { allowNonZeroExit: true, + env: STATUS_UPSTREAM_REFRESH_ENV, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), }, ).pipe(Effect.asVoid); }; const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { - const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ + const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [ "rev-parse", "--git-common-dir", ]).pipe(Effect.map((stdout) => stdout.trim())); @@ -978,13 +925,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { remoteName: string, ): Effect.Effect => executeGit( - "GitCore.resolveDefaultBranchName", + "GitVcsDriver.resolveDefaultBranchName", cwd, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], { allowNonZeroExit: true }, ).pipe( Effect.map((result) => { - if (result.code !== 0) { + if (result.exitCode !== 0) { return null; } return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); @@ -994,27 +941,36 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const remoteBranchExists = ( cwd: string, remoteName: string, - branch: string, + refName: string, ): Effect.Effect => executeGit( - "GitCore.remoteBranchExists", + "GitVcsDriver.remoteBranchExists", cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`], { allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.code === 0)); + ).pipe(Effect.map((result) => result.exitCode === 0)); const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { allowNonZeroExit: true, - }).pipe(Effect.map((result) => result.code === 0)); + }).pipe(Effect.map((result) => result.exitCode === 0)); const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => - runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), ); + const resolvePublishBranchName = Effect.fn("resolvePublishBranchName")(function* ( + cwd: string, + branchName: string, + ) { + const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.catch(() => Effect.succeed([]))); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(branchName, remoteNames); + return parsedRemoteRef?.branchName ?? branchName; + }); + const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { if (yield* originRemoteExists(cwd)) { return "origin"; @@ -1025,7 +981,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return firstRemote; } return yield* createGitCommandError( - "GitCore.resolvePrimaryRemoteName", + "GitVcsDriver.resolvePrimaryRemoteName", cwd, ["remote"], "No git remote is configured for this repository.", @@ -1034,12 +990,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( cwd: string, - branch: string, + refName: string, ) { const branchPushRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.branchPushRemote", + "GitVcsDriver.resolvePushRemoteName.branchPushRemote", cwd, - ["config", "--get", `branch.${branch}.pushRemote`], + ["config", "--get", `branch.${refName}.pushRemote`], true, ).pipe(Effect.map((stdout) => stdout.trim())); if (branchPushRemote.length > 0) { @@ -1047,7 +1003,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } const pushDefaultRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.remotePushDefault", + "GitVcsDriver.resolvePushRemoteName.remotePushDefault", cwd, ["config", "--get", "remote.pushDefault"], true, @@ -1059,39 +1015,47 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); }); - const ensureRemote: GitCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) { - const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); - const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [ - "remote", - "-v", - ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - - for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { - return remoteName; + const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( + function* (input) { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitVcsDriver.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; + } } - } - let remoteName = preferredName; - let suffix = 1; - while (remoteFetchUrls.has(remoteName)) { - remoteName = `${preferredName}-${suffix}`; - suffix += 1; - } + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } - yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]); - return remoteName; - }); + yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }, + ); const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, - branch: string, + refName: string, ) { const configuredBaseBranch = yield* runGitStdout( - "GitCore.resolveBaseBranchForNoUpstream.config", + "GitVcsDriver.resolveBaseBranchForNoUpstream.config", cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], + ["config", "--get", `branch.${refName}.gh-merge-base`], true, ).pipe(Effect.map((stdout) => stdout.trim())); @@ -1118,7 +1082,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { : remotePrefix && candidate.startsWith(remotePrefix) ? candidate.slice(remotePrefix.length) : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { + if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { continue; } @@ -1139,20 +1103,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( cwd: string, - branch: string, + refName: string, ) { - const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); - if (!baseBranch) { + const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName); + if (!baseRef) { return 0; } const result = yield* executeGit( - "GitCore.computeAheadCountAgainstBase", + "GitVcsDriver.computeAheadCountAgainstBase", cwd, - ["rev-list", "--count", `${baseBranch}..HEAD`], + ["rev-list", "--count", `${baseRef}..HEAD`], { allowNonZeroExit: true }, ); - if (result.code !== 0) { + if (result.exitCode !== 0) { return 0; } @@ -1162,7 +1126,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { const branchRecency = yield* executeGit( - "GitCore.readBranchRecency", + "GitVcsDriver.readBranchRecency", cwd, [ "for-each-ref", @@ -1177,7 +1141,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); const branchLastCommit = new Map(); - if (branchRecency.code !== 0) { + if (branchRecency.exitCode !== 0) { return branchLastCommit; } @@ -1198,7 +1162,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGit( - "GitCore.statusDetails.status", + "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], { @@ -1210,27 +1174,27 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return NON_REPOSITORY_STATUS_DETAILS; } - if (statusResult.code !== 0) { + if (statusResult.exitCode !== 0) { const stderr = statusResult.stderr.trim(); return yield* createGitCommandError( - "GitCore.statusDetails.status", + "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], stderr || "git status failed", ); } - const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = + const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( [ - runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ + runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitVcsDriver.statusDetails.stagedNumstat", cwd, [ "diff", "--cached", "--numstat", ]), executeGit( - "GitCore.statusDetails.defaultRef", + "GitVcsDriver.statusDetails.defaultRef", cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { @@ -1243,21 +1207,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); const statusStdout = statusResult.stdout; const defaultBranch = - defaultRefResult.code === 0 + defaultRefResult.exitCode === 0 ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; - let branch: string | null = null; + let refName: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; let behindCount = 0; + let aheadOfDefaultCount = 0; let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); for (const line of statusStdout.split(/\r?\n/g)) { if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); - branch = value.startsWith("(") ? null : value; + refName = value.startsWith("(") ? null : value; continue; } if (line.startsWith("# branch.upstream ")) { @@ -1279,13 +1244,31 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } } - if (!upstreamRef && branch) { - aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(0)), - ); + const fallbackAheadCount = + !upstreamRef && refName + ? yield* computeAheadCountAgainstBase(cwd, refName).pipe( + Effect.catch(() => Effect.succeed(0)), + ) + : null; + + if (fallbackAheadCount !== null) { + aheadCount = fallbackAheadCount; behindCount = 0; } + const isDefaultBranch = + refName !== null && + (refName === defaultBranch || + (defaultBranch === null && (refName === "main" || refName === "master"))); + if (refName && !isDefaultBranch) { + aheadOfDefaultCount = + fallbackAheadCount !== null + ? fallbackAheadCount + : yield* computeAheadCountAgainstBase(cwd, refName).pipe( + Effect.catch(() => Effect.succeed(0)), + ); + } + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map(); @@ -1314,12 +1297,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return { isRepo: true, - hasOriginRemote, - isDefaultBranch: - branch !== null && - (branch === defaultBranch || - (defaultBranch === null && (branch === "main" || branch === "master"))), - branch, + hasOriginRemote: hasPrimaryRemote, + isDefaultBranch, + branch: refName, upstreamRef, hasWorkingTreeChanges, workingTree: { @@ -1330,72 +1310,76 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { hasUpstream: upstreamRef !== null, aheadCount, behindCount, + aheadOfDefaultCount, }; }); - const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( + const statusDetailsLocal: GitVcsDriver.GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( + "statusDetailsLocal", + )(function* (cwd) { + return yield* readStatusDetailsLocal(cwd); + }); + + const statusDetails: GitVcsDriver.GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); return yield* readStatusDetailsLocal(cwd); }, ); - const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), - Effect.ignoreCause({ log: true }), - ); - return yield* readStatusDetailsLocal(cwd); - }); - - const status: GitCoreShape["status"] = (input) => + const status: GitVcsDriver.GitVcsDriverShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, + aheadOfDefaultCount: details.aheadOfDefaultCount, pr: null, })), ); - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn( + const prepareCommitContext: GitVcsDriver.GitVcsDriverShape["prepareCommitContext"] = Effect.fn( "prepareCommitContext", )(function* (cwd, filePaths) { if (filePaths && filePaths.length > 0) { - yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( + yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( Effect.catch(() => Effect.void), ); - yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ "add", "-A", "--", ...filePaths, ]); } else { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); } - const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ - "diff", - "--cached", - "--name-status", - ]).pipe(Effect.map((stdout) => stdout.trim())); + const stagedSummary = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { return null; } const stagedPatch = yield* runGitStdoutWithOptions( - "GitCore.prepareCommitContext.stagedPatch", + "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, ["diff", "--cached", "--patch", "--minimal"], { maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, + appendTruncationMarker: true, }, ); @@ -1405,11 +1389,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; }); - const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* ( + const commit: GitVcsDriver.GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( cwd, subject, body, - options?: GitCommitOptions, + options?: GitVcsDriver.GitCommitOptions, ) { const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); @@ -1426,11 +1410,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; - yield* executeGit("GitCore.commit.commit", cwd, args, { + yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); - const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ + const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", ]).pipe(Effect.map((stdout) => stdout.trim())); @@ -1438,656 +1422,610 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return { commitSha }; }); - const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( - function* (cwd, fallbackBranch) { - const details = yield* statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", - ); - } - - const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { - return { - status: "skipped_up_to_date" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } - - const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!publishRemoteName) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } + const pushCurrentBranch: GitVcsDriver.GitVcsDriverShape["pushCurrentBranch"] = Effect.fn( + "pushCurrentBranch", + )(function* (cwd, fallbackBranch, options) { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* createGitCommandError( + "GitVcsDriver.pushCurrentBranch", + cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.catch(() => Effect.succeed(false)), - ); - if (hasRemoteBranch) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - } - } + const requestedRemoteName = options?.remoteName?.trim() || null; + if (requestedRemoteName) { + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ + "push", + "-u", + requestedRemoteName, + `HEAD:refs/heads/${publishBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${requestedRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); - } - yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", - publishRemoteName, - `HEAD:refs/heads/${branch}`, - ]); + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; + if (hasNoLocalDelta) { + if (details.hasUpstream) { return { - status: "pushed" as const, + status: "skipped_up_to_date" as const, branch, - upstreamBranch: `${publishRemoteName}/${branch}`, - setUpstream: true, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), }; } - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( Effect.catch(() => Effect.succeed(null)), ); - if (currentUpstream) { - yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ - "push", - currentUpstream.remoteName, - `HEAD:${currentUpstream.upstreamBranch}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: currentUpstream.upstreamRef, - setUpstream: false, - }; - } - - yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); - return { - status: "pushed" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - setUpstream: false, - }; - }, - ); + if (comparableBaseBranch) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (!publishRemoteName) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } - const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( - function* (cwd) { - const details = yield* statusDetails(cwd); - const branch = details.branch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", + const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( + Effect.catch(() => Effect.succeed(false)), ); + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } } - if (!details.hasUpstream) { + } + + if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { return yield* createGitCommandError( - "GitCore.pullCurrentBranch", + "GitVcsDriver.pushCurrentBranch", cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", + ["push"], + "Cannot push because no git remote is configured for this repository.", ); } - const beforeSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.beforeSha", - cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", - }); - const afterSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.afterSha", - cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - const refreshed = yield* statusDetails(cwd); + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ + "push", + "-u", + publishRemoteName, + `HEAD:refs/heads/${publishBranch}`, + ]); return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + status: "pushed" as const, branch, - upstreamBranch: refreshed.upstreamRef, + upstreamBranch: `${publishRemoteName}/${publishBranch}`, + setUpstream: true, }; - }, - ); - - const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( - function* (cwd, baseBranch) { - const range = `${baseBranch}..HEAD`; - const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( - [ - runGitStdoutWithOptions( - "GitCore.readRangeContext.log", - cwd, - ["log", "--oneline", range], - { - maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitCore.readRangeContext.diffStat", - cwd, - ["diff", "--stat", range], - { - maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitCore.readRangeContext.diffPatch", - cwd, - ["diff", "--patch", "--minimal", range], - { - maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - ], - { concurrency: "unbounded" }, - ); + } + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (currentUpstream) { + yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:refs/heads/${currentUpstream.branchName}`, + ]); return { - commitSummary, - diffSummary, - diffPatch, + status: "pushed" as const, + branch, + upstreamBranch: currentUpstream.upstreamRef, + setUpstream: false, }; - }, - ); - - const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => - runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( - Effect.map((stdout) => stdout.trim()), - Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), - ); + } - const isInsideWorkTree: GitCoreShape["isInsideWorkTree"] = (cwd) => - executeGit("GitCore.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], { - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 4_096, - }).pipe(Effect.map((result) => result.code === 0 && result.stdout.trim() === "true")); + yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }); - const listWorkspaceFiles: GitCoreShape["listWorkspaceFiles"] = (cwd) => - executeGit( - "GitCore.listWorkspaceFiles", + const pullCurrentBranch: GitVcsDriver.GitVcsDriverShape["pullCurrentBranch"] = Effect.fn( + "pullCurrentBranch", + )(function* (cwd) { + const details = yield* statusDetails(cwd); + const refName = details.branch; + if (!refName) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Cannot pull from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Current branch has no upstream configured. Push with upstream first.", + ); + } + const beforeSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.beforeSha", cwd, - [ - ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ], - { - allowNonZeroExit: true, - timeoutMs: 20_000, - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ).pipe( - Effect.flatMap((result) => - result.code === 0 - ? Effect.succeed({ - paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), - truncated: result.stdoutTruncated, - }) - : Effect.fail( - createGitCommandError( - "GitCore.listWorkspaceFiles", - cwd, - [ - ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ], - result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed", - ), - ), - ), - ); - - const filterIgnoredPaths: GitCoreShape["filterIgnoredPaths"] = (cwd, relativePaths) => - Effect.gen(function* () { - if (relativePaths.length === 0) { - return relativePaths; - } + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", + }); + const afterSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.afterSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); - const ignoredPaths = new Set(); - const chunks = chunkPathsForGitCheckIgnore(relativePaths); + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + refName, + upstreamRef: refreshed.upstreamRef, + }; + }); - for (const chunk of chunks) { - const result = yield* executeGit( - "GitCore.filterIgnoredPaths", + const readRangeContext: GitVcsDriver.GitVcsDriverShape["readRangeContext"] = Effect.fn( + "readRangeContext", + )(function* (cwd, baseRef) { + const range = `${baseRef}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.log", cwd, - [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], + ["log", "--oneline", range], { - stdin: `${chunk.join("\0")}\0`, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, }, - ); - - if (result.code !== 0 && result.code !== 1) { - return yield* createGitCommandError( - "GitCore.filterIgnoredPaths", - cwd, - [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], - result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed", - ); - } - - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { - ignoredPaths.add(ignoredPath); - } - } - - if (ignoredPaths.size === 0) { - return relativePaths; - } + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffStat", + cwd, + ["diff", "--stat", range], + { + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffPatch", + cwd, + ["diff", "--patch", "--minimal", range], + { + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ), + ], + { concurrency: "unbounded" }, + ); - return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); - }); + return { + commitSummary, + diffSummary, + diffPatch, + }; + }); - const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), - ); - const localBranchResult = yield* executeGit( - "GitCore.listBranches.branchNoColor", - input.cwd, - ["branch", "--no-color", "--no-column"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchIf(isMissingGitCwdError, () => - Effect.succeed({ - code: 128, - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), + const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => + runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - if (localBranchResult.code !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return { - branches: [], - isRepo: false, - hasOriginRemote: false, - nextCursor: null, - totalCount: 0, - }; - } - return yield* createGitCommandError( - "GitCore.listBranches", + const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")( + function* (input) { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), + ); + const localBranchResult = yield* executeGit( + "GitVcsDriver.listRefs.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catchIf(isMissingGitCwdError, () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(128), + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), ); - } - const remoteBranchResultEffect = executeGit( - "GitCore.listBranches.remoteBranches", - input.cwd, - ["branch", "--no-color", "--no-column", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), - ), - ); - - const remoteNamesResultEffect = executeGit( - "GitCore.listBranches.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), - ), - ); + if (localBranchResult.exitCode !== 0) { + const stderr = localBranchResult.stderr.trim(); + if (stderr.toLowerCase().includes("not a git repository")) { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + return yield* createGitCommandError( + "GitVcsDriver.listRefs", + input.cwd, + ["branch", "--no-color", "--no-column"], + stderr || "git branch failed", + ); + } - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitCore.listBranches.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitCore.listBranches.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, + const remoteBranchResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteBranches", + input.cwd, + ["branch", "--no-color", "--no-column", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, - ], - { concurrency: "unbounded" }, + ), ); - const remoteNames = - remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, - ); - } - if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + const remoteNamesResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteNames", + input.cwd, + ["remote"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies GitVcsDriver.ExecuteGitResult), + ), + ), ); - } - const defaultBranch = - defaultRef.code === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; + const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = + yield* Effect.all( + [ + executeGit( + "GitVcsDriver.listRefs.defaultRef", + input.cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listRefs.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + remoteBranchResultEffect, + remoteNamesResultEffect, + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); - const worktreeMap = new Map(); - if (worktreeList.code === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; + const remoteNames = + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, + ); + } + if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } + + const defaultBranch = + defaultRef.exitCode === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const worktreeMap = new Map(); + if (worktreeList.exitCode === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + const candidatePath = line.slice("worktree ".length); + const exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + currentPath = exists ? candidatePath : null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; + } } } - } - const localBranches = localBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => ({ - name: branch.name, - current: branch.current, - isRemote: false, - isDefault: branch.name === defaultBranch, - worktreePath: worktreeMap.get(branch.name) ?? null, - })) - .toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); + const localBranches = localBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => ({ + name: refName.name, + current: refName.current, + isRemote: false, + isDefault: refName.name === defaultBranch, + worktreePath: worktreeMap.get(refName.name) ?? null, + })) + .toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + if (aPriority !== bPriority) return aPriority - bPriority; + + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }); + + const remoteBranches = + remoteBranchResult.exitCode === 0 + ? remoteBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => { + const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); + const remoteBranch: { + name: string; + current: boolean; + isRemote: boolean; + remoteName?: string; + isDefault: boolean; + worktreePath: string | null; + } = { + name: refName.name, + current: false, + isRemote: true, + isDefault: false, + worktreePath: null, + }; + if (parsedRemoteRef) { + remoteBranch.remoteName = parsedRemoteRef.remoteName; + } + return remoteBranch; + }) + .toSorted((a, b) => { + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }) + : []; + + const refs = paginateBranches({ + refs: filterBranchesForListQuery( + dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), + input.query, + ), + cursor: input.cursor, + limit: input.limit, }); - const remoteBranches = - remoteBranchResult.code === 0 - ? remoteBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => { - const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: branch.name, - current: false, - isRemote: true, - isDefault: false, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return remoteBranch; - }) - .toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; - - const branches = paginateBranches({ - branches: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), - cursor: input.cursor, - limit: input.limit, + return { + refs: [...refs.refs], + isRepo: true, + hasPrimaryRemote: remoteNames.includes("origin"), + nextCursor: refs.nextCursor, + totalCount: refs.totalCount, + }; + }, + ); + + const createWorktree: GitVcsDriver.GitVcsDriverShape["createWorktree"] = Effect.fn( + "createWorktree", + )(function* (input) { + const targetBranch = input.newRefName ?? input.refName; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const args = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { + fallbackErrorMessage: "git worktree add failed", }); return { - branches: [...branches.branches], - isRepo: true, - hasOriginRemote: remoteNames.includes("origin"), - nextCursor: branches.nextCursor, - totalCount: branches.totalCount, + worktree: { + path: worktreePath, + refName: targetBranch, + }, }; }); - const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( - function* (input) { - const targetBranch = input.newBranch ?? input.branch; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newBranch - ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] - : ["worktree", "add", worktreePath, input.branch]; - - yield* executeGit("GitCore.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", - }); - - return { - worktree: { - path: worktreePath, - branch: targetBranch, + const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] = + Effect.fn("fetchPullRequestBranch")(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchPullRequestBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", }, - }; - }, - ); + ); + }); - const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn( - "fetchPullRequestBranch", + const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn( + "fetchRemoteBranch", )(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitCore.fetchPullRequestBranch", + yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitVcsDriver.fetchRemoteBranch.materialize", input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorMessage: "git fetch pull request branch failed", - }, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], ); }); - const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( - function* (input) { - yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ + const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteTrackingBranch"] = + Effect.fn("fetchRemoteTrackingBranch")(function* (input) { + yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ "fetch", "--quiet", "--no-tags", input.remoteName, `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, ]); + }); - const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); - const targetRef = `${input.remoteName}/${input.remoteBranch}`; - yield* runGit( - "GitCore.fetchRemoteBranch.materialize", - input.cwd, - localBranchAlreadyExists - ? ["branch", "--force", input.localBranch, targetRef] - : ["branch", input.localBranch, targetRef], - ); - }, - ); - - const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => - runGit("GitCore.setBranchUpstream", input.cwd, [ + const setBranchUpstream: GitVcsDriver.GitVcsDriverShape["setBranchUpstream"] = (input) => + runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", "--set-upstream-to", `${input.remoteName}/${input.remoteBranch}`, input.branch, ]); - const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")( + const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( + "removeWorktree", + )(function* (input) { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.mapError((error) => + createGitCommandError( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, + error, + ), + ), + ); + }); + + const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( function* (input) { - const args = ["worktree", "remove"]; - if (input.force) { - args.push("--force"); + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; } - args.push(input.path); - yield* executeGit("GitCore.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitCore.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, - error, - ), - ), + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + + yield* executeGit( + "GitVcsDriver.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch rename failed", + }, ); + + return { branch: targetBranch }; }, ); - const renameBranch: GitCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* (input) { - if (input.oldBranch === input.newBranch) { - return { branch: input.newBranch }; - } - const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); - - yield* executeGit( - "GitCore.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", - }, - ); - - return { branch: targetBranch }; - }); - - const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( + const switchRef: GitVcsDriver.GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")( function* (input) { const [localInputExists, remoteExists] = yield* Effect.all( [ executeGit( - "GitCore.checkoutBranch.localInputExists", + "GitVcsDriver.switchRef.localInputExists", input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.code === 0)), + ).pipe(Effect.map((result) => result.exitCode === 0)), executeGit( - "GitCore.checkoutBranch.remoteExists", + "GitVcsDriver.switchRef.remoteExists", input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.code === 0)), + ).pipe(Effect.map((result) => result.exitCode === 0)), ], { concurrency: "unbounded" }, ); const localTrackingBranch = remoteExists ? yield* executeGit( - "GitCore.checkoutBranch.localTrackingBranch", + "GitVcsDriver.switchRef.localTrackingBranch", input.cwd, ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], { @@ -2096,71 +2034,73 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ).pipe( Effect.map((result) => - result.code === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) + result.exitCode === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) : null, ), ) : null; - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); const localTrackedBranchTargetExists = remoteExists && localTrackedBranchCandidate ? yield* executeGit( - "GitCore.checkoutBranch.localTrackedBranchTargetExists", + "GitVcsDriver.switchRef.localTrackedBranchTargetExists", input.cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.code === 0)) + ).pipe(Effect.map((result) => result.exitCode === 0)) : false; const checkoutArgs = localInputExists - ? ["checkout", input.branch] + ? ["checkout", input.refName] : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.branch] + ? ["checkout", input.refName] : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.branch] + ? ["checkout", "--track", input.refName] : remoteExists && localTrackingBranch ? ["checkout", localTrackingBranch] - : ["checkout", input.branch]; + : ["checkout", input.refName]; - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); - const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ + const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ "branch", "--show-current", ]).pipe(Effect.map((stdout) => stdout.trim() || null)); - return { branch }; + return { refName }; }, ); - const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { - yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }); - if (input.checkout) { - yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); - } + const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")( + function* (input) { + yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }); + if (input.switchRef) { + yield* switchRef({ cwd: input.cwd, refName: input.refName }); + } - return { branch: input.branch }; - }); + return { refName: input.refName }; + }, + ); - const initRepo: GitCoreShape["initRepo"] = (input) => - executeGit("GitCore.initRepo", input.cwd, ["init"], { + const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => + executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, fallbackErrorMessage: "git init failed", }).pipe(Effect.asVoid); - const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => - runGitStdout("GitCore.listLocalBranchNames", cwd, [ + const listLocalBranchNames: GitVcsDriver.GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ "branch", "--list", "--no-column", @@ -2174,7 +2114,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ), ); - return { + return GitVcsDriver.GitVcsDriver.of({ execute, status, statusDetails, @@ -2185,22 +2125,19 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { pullCurrentBranch, readRangeContext, readConfigValue, - isInsideWorkTree, - listWorkspaceFiles, - filterIgnoredPaths, - listBranches, + listRefs, createWorktree, fetchPullRequestBranch, ensureRemote, + resolvePrimaryRemoteName, fetchRemoteBranch, + fetchRemoteTrackingBranch, setBranchUpstream, removeWorktree, renameBranch, - createBranch, - checkoutBranch, + createRef, + switchRef, initRepo, listLocalBranchNames, - } satisfies GitCoreShape; + }); }); - -export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts new file mode 100644 index 00000000000..9e6a6a4021c --- /dev/null +++ b/apps/server/src/vcs/VcsDriver.ts @@ -0,0 +1,72 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { + VcsDriverCapabilities, + VcsError, + VcsInitInput, + VcsListRemotesResult, + VcsListWorkspaceFilesResult, + VcsRepositoryIdentity, +} from "@t3tools/contracts"; +import { CheckpointRef } from "@t3tools/contracts"; +import * as VcsProcess from "./VcsProcess.ts"; + +export interface VcsCaptureCheckpointInput { + readonly cwd: string; + readonly checkpointRef: CheckpointRef; +} + +export interface VcsRestoreCheckpointInput { + readonly cwd: string; + readonly checkpointRef: CheckpointRef; + readonly fallbackToHead?: boolean; +} + +export interface VcsDiffCheckpointsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; + readonly fallbackFromToHead?: boolean; + readonly ignoreWhitespace: boolean; +} + +export interface VcsDeleteCheckpointRefsInput { + readonly cwd: string; + readonly checkpointRefs: ReadonlyArray; +} + +export interface VcsCheckpointOps { + readonly captureCheckpoint: (input: VcsCaptureCheckpointInput) => Effect.Effect; + readonly hasCheckpointRef: ( + input: Omit, + ) => Effect.Effect; + readonly restoreCheckpoint: ( + input: VcsRestoreCheckpointInput, + ) => Effect.Effect; + readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect; + readonly deleteCheckpointRefs: ( + input: VcsDeleteCheckpointRefsInput, + ) => Effect.Effect; +} + +export interface VcsDriverShape { + readonly capabilities: VcsDriverCapabilities; + readonly execute: ( + input: Omit, + ) => Effect.Effect; + readonly checkpoints?: VcsCheckpointOps; + readonly detectRepository: (cwd: string) => Effect.Effect; + readonly isInsideWorkTree: (cwd: string) => Effect.Effect; + readonly listWorkspaceFiles: ( + cwd: string, + ) => Effect.Effect; + readonly listRemotes: (cwd: string) => Effect.Effect; + readonly filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, VcsError>; + readonly initRepository: (input: VcsInitInput) => Effect.Effect; +} + +export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts new file mode 100644 index 00000000000..03c09c16be8 --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -0,0 +1,95 @@ +import { assert, it, describe } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as VcsProcess from "./VcsProcess.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => + args[0] === "-C" && args.length >= 2 ? args.slice(2) : args; + +describe("VcsDriverRegistry", () => { + it.effect("routes directly by VCS driver kind for non-repository workflows", () => { + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => Effect.succeed(processOutput("")), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const driver = yield* registry.get("git"); + + assert.strictEqual(driver.capabilities.kind, "git"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make()).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + const normalizedArgs = + input.args[0] === "-C" && input.args.length >= 2 ? input.args.slice(2) : input.args; + const command = normalizedArgs.join(" "); + if (command === "rev-parse --is-inside-work-tree") { + return processOutput("true\n"); + } + if (command === "rev-parse --show-toplevel") { + return processOutput("/repo\n"); + } + if (command === "rev-parse --git-common-dir") { + return processOutput("/repo/.git\n"); + } + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + + assert.equal(first.repository.rootPath, "/repo"); + assert.equal(second.repository.rootPath, "/repo"); + assert.deepStrictEqual( + calls.map((call) => normalizeGitArgs(call.args).join(" ")), + [ + "rev-parse --is-inside-work-tree", + "rev-parse --show-toplevel", + "rev-parse --git-common-dir", + ], + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts new file mode 100644 index 00000000000..22868855737 --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -0,0 +1,160 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; +import { VcsUnsupportedOperationError } from "@t3tools/contracts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; +import * as VcsDriver from "./VcsDriver.ts"; + +const DETECTION_CACHE_CAPACITY = 2_048; +const DETECTION_CACHE_TTL = Duration.seconds(2); + +export interface VcsDriverResolveInput { + readonly cwd: string; + readonly requestedKind?: VcsDriverKind | "auto"; +} + +export interface VcsDriverHandle { + readonly kind: VcsDriverKind; + readonly repository: VcsRepositoryIdentity; + readonly driver: VcsDriver.VcsDriverShape; +} + +export interface VcsDriverRegistryShape { + readonly get: (kind: VcsDriverKind) => Effect.Effect; + readonly detect: ( + input: VcsDriverResolveInput, + ) => Effect.Effect; + readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; +} + +export class VcsDriverRegistry extends Context.Service()( + "t3/vcs/VcsDriverRegistry", +) {} + +const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => + new VcsUnsupportedOperationError({ + operation, + kind, + detail, + }); + +function detectionCacheKey(input: { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; +}): string { + return `${input.requestedKind}\0${input.cwd}`; +} + +function parseDetectionCacheKey(key: string): { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; +} { + const separatorIndex = key.indexOf("\0"); + if (separatorIndex === -1) { + return { + cwd: key, + requestedKind: "auto", + }; + } + return { + requestedKind: key.slice(0, separatorIndex) as VcsDriverKind | "auto", + cwd: key.slice(separatorIndex + 1), + }; +} + +export const make = Effect.fn("makeVcsDriverRegistry")(function* () { + const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; + const git = yield* GitVcsDriver.makeVcsDriverShape(); + const drivers: Partial> = { + git, + }; + + const get: VcsDriverRegistryShape["get"] = (kind) => { + const driver = drivers[kind]; + if (!driver) { + return Effect.fail( + unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`), + ); + } + return Effect.succeed(driver); + }; + + const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( + kind: VcsDriverKind, + driver: VcsDriver.VcsDriverShape, + cwd: string, + ) { + const repository = yield* driver.detectRepository(cwd); + if (!repository) { + return null; + } + return { + kind, + repository, + driver, + } satisfies VcsDriverHandle; + }); + + const detectResolvedKind = Effect.fn("VcsDriverRegistry.detectResolvedKind")(function* (input: { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; + }) { + const requestedKind = input.requestedKind; + + if (requestedKind !== "auto" && requestedKind !== "unknown") { + const driver = yield* get(requestedKind); + return yield* detectWithDriver(requestedKind, driver, input.cwd); + } + + return yield* detectWithDriver("git", git, input.cwd); + }); + + const detectionCache = yield* Cache.makeWith( + (key) => detectResolvedKind(parseDetectionCacheKey(key)), + { + capacity: DETECTION_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? DETECTION_CACHE_TTL : Duration.zero), + }, + ); + + const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( + function* (input) { + const requestedKind = yield* projectConfig.resolveKind(input); + return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); + }, + ); + + const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( + function* (input) { + const detected = yield* detect(input); + if (detected) { + return detected; + } + + const requestedKind = input.requestedKind ?? "auto"; + return yield* unsupported( + "VcsDriverRegistry.resolve", + requestedKind === "auto" ? "unknown" : requestedKind, + requestedKind === "auto" + ? `No supported VCS repository was detected at ${input.cwd}.` + : `No ${requestedKind} repository was detected at ${input.cwd}.`, + ); + }, + ); + + return VcsDriverRegistry.of({ + get, + detect, + resolve, + }); +}); + +export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( + Layer.provide(VcsProjectConfig.layer), +); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts new file mode 100644 index 00000000000..b58d64e435a --- /dev/null +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -0,0 +1,138 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +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 { TestClock } from "effect/testing"; + +import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import * as VcsProcess from "./VcsProcess.ts"; + +const run = (input: VcsProcess.VcsProcessInput) => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + return yield* process.run(input); + }); + +const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); + +const provideLive = (effect: Effect.Effect) => + effect.pipe(Effect.provide(liveLayer)); + +describe("VcsProcess.run", () => { + it.effect("collects stdout", () => + Effect.gen(function* () { + const result = yield* run({ + operation: "test.stdout", + command: "node", + args: ["-e", "process.stdout.write('hello')"], + cwd: process.cwd(), + }); + + expect(result.stdout).toBe("hello"); + expect(result.stderr).toBe(""); + expect(result.stdoutTruncated).toBe(false); + expect(result.stderrTruncated).toBe(false); + }).pipe(provideLive), + ); + + it.effect("writes stdin before waiting for exit", () => + Effect.gen(function* () { + const result = yield* run({ + operation: "test.stdin", + command: "node", + args: [ + "-e", + [ + "process.stdin.setEncoding('utf8');", + "let data='';", + "process.stdin.on('data', chunk => { data += chunk; });", + "process.stdin.on('end', () => { process.stdout.write(data); });", + ].join(""), + ], + cwd: process.cwd(), + stdin: "stdin payload", + }); + + expect(result.stdout).toBe("stdin payload"); + }).pipe(provideLive), + ); + + it.effect("fails with VcsProcessExitError for non-zero exits by default", () => + Effect.gen(function* () { + const error = yield* run({ + operation: "test.exit", + command: "node", + args: ["-e", "process.stderr.write('boom'); process.exit(2)"], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + }).pipe(provideLive), + ); + + it.effect("returns output when non-zero exits are allowed", () => + Effect.gen(function* () { + const result = yield* run({ + operation: "test.allowed-exit", + command: "node", + args: ["-e", "process.stderr.write('boom'); process.exit(2)"], + cwd: process.cwd(), + allowNonZeroExit: true, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe("boom"); + }).pipe(provideLive), + ); + + it.effect("truncates output and appends the marker when requested", () => + Effect.gen(function* () { + const result = yield* run({ + operation: "test.truncate-marker", + command: "node", + args: ["-e", "process.stdout.write('x'.repeat(2048))"], + cwd: process.cwd(), + maxOutputBytes: 128, + appendTruncationMarker: true, + }); + + expect(result.stdoutTruncated).toBe(true); + expect(result.stdout).toContain("[truncated]"); + expect(result.stderrTruncated).toBe(false); + }).pipe(provideLive), + ); + + it.effect("truncates without the marker when truncation markers are disabled", () => + Effect.gen(function* () { + const result = yield* run({ + operation: "test.truncate-silent", + command: "node", + args: ["-e", "process.stdout.write('x'.repeat(2048))"], + cwd: process.cwd(), + maxOutputBytes: 128, + }); + + expect(result.stdoutTruncated).toBe(true); + expect(result.stdout).not.toContain("[truncated]"); + }).pipe(provideLive), + ); + + it.effect("fails with VcsProcessTimeoutError on timeout", () => + Effect.gen(function* () { + const errorFiber = yield* run({ + operation: "test.timeout", + command: "node", + args: ["-e", "setTimeout(() => {}, 5000)"], + cwd: process.cwd(), + timeoutMs: 50, + }).pipe(Effect.flip, Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(50)); + const error = yield* Fiber.join(errorFiber); + + expect(error).toBeInstanceOf(VcsProcessTimeoutError); + }).pipe(provideLive), + ); +}); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts new file mode 100644 index 00000000000..a4caf7d3230 --- /dev/null +++ b/apps/server/src/vcs/VcsProcess.ts @@ -0,0 +1,122 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + VcsOutputDecodeError, + type VcsError, + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; +import { ProcessRunner, layer as ProcessRunnerLive } from "../processRunner.ts"; +import * as Match from "effect/Match"; + +export interface VcsProcessInput { + readonly operation: string; + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly spawnCwd?: string; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly appendTruncationMarker?: boolean; +} + +export interface VcsProcessOutput { + readonly exitCode: ChildProcessSpawner.ExitCode; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface VcsProcessShape { + readonly run: (input: VcsProcessInput) => Effect.Effect; +} + +export class VcsProcess extends Context.Service()( + "t3/vcs/VcsProcess", +) {} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; + +function commandLabel(command: string, args: ReadonlyArray): string { + return [command, ...args].join(" "); +} + +export const make = Effect.fn("makeVcsProcess")(function* () { + const processRunner = yield* ProcessRunner; + + const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const label = commandLabel(input.command, input.args); + const baseError = { + operation: input.operation, + command: label, + cwd: input.cwd, + }; + + const result = yield* processRunner + .run({ + command: input.command, + args: input.args, + cwd: input.cwd, + ...(input.spawnCwd !== undefined ? { spawnCwd: input.spawnCwd } : {}), + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, + outputMode: "truncate", + truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "", + timeoutBehavior: "error", + }) + .pipe( + Effect.mapError( + Match.valueTags({ + ProcessSpawnError: (error) => + VcsProcessSpawnError.fromProcessSpawnError(baseError, error), + ProcessOutputLimitError: (error) => + VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), + ProcessTimeoutError: (error) => + VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), + ProcessStdinError: (error) => + VcsOutputDecodeError.fromProcessStdinError(baseError, error), + ProcessReadError: (error) => + VcsOutputDecodeError.fromProcessReadError(baseError, error), + }), + ), + ); + + if (result.code === null) { + return yield* VcsOutputDecodeError.missingExitCode(baseError); + } + + if (!input.allowNonZeroExit && result.code !== 0) { + return yield* new VcsProcessExitError({ + operation: input.operation, + command: label, + cwd: input.cwd, + exitCode: result.code, + detail: result.stderr.trim() || `${label} exited with code ${result.code}.`, + }); + } + + return { + exitCode: result.code, + stdout: result.stdout, + stderr: result.stderr, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + } satisfies VcsProcessOutput; + }); + + return VcsProcess.of({ run }); +}); + +export const layer = Layer.effect(VcsProcess, make()).pipe(Layer.provide(ProcessRunnerLive)); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts new file mode 100644 index 00000000000..aac4beb7e32 --- /dev/null +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -0,0 +1,70 @@ +import { assert, it, describe } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; + +const TestLayer = VcsProjectConfig.layer.pipe( + Layer.provide(NodeServices.layer), + Layer.provideMerge(NodeServices.layer), +); + +describe("VcsProjectConfig", () => { + it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { + it.effect("returns the requested kind", () => + Effect.gen(function* () { + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ + cwd: "/repo", + requestedKind: "jj", + }); + + assert.equal(kind, "jj"); + }), + ); + }); + + it.layer(TestLayer)("discovers .t3code/vcs.json from nested workspaces", (it) => { + it.effect("returns the configured kind", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + const nested = path.join(root, "packages", "app"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ vcs: { kind: "jj" } }), + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: nested }); + + assert.equal(kind, "jj"); + }), + ); + }); + + it.layer(TestLayer)("falls back to auto when no config exists", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); +}); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts new file mode 100644 index 00000000000..3e5ee2347ce --- /dev/null +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -0,0 +1,123 @@ +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts"; + +const ProjectVcsConfig = Schema.Struct({ + vcs: Schema.optional( + Schema.Struct({ + kind: Schema.optional(VcsDriverKind), + }), + ), + vcsKind: Schema.optional(VcsDriverKind), +}); +const isProjectVcsConfig = Schema.is(ProjectVcsConfig); + +interface ProjectVcsConfigFile { + readonly vcs?: + | { + readonly kind?: VcsDriverKindType | undefined; + } + | undefined; + readonly vcsKind?: VcsDriverKindType | undefined; +} + +export interface VcsProjectConfigResolveInput { + readonly cwd: string; + readonly requestedKind?: VcsDriverKindType | "auto"; +} + +export interface VcsProjectConfigShape { + readonly resolveKind: ( + input: VcsProjectConfigResolveInput, + ) => Effect.Effect; +} + +export class VcsProjectConfig extends Context.Service()( + "t3/vcs/VcsProjectConfig", +) {} + +function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" { + return config.vcs?.kind ?? config.vcsKind ?? "auto"; +} + +function parseConfig(raw: string): ProjectVcsConfigFile | null { + try { + const parsed = JSON.parse(raw) as unknown; + return isProjectVcsConfig(parsed) ? parsed : null; + } catch { + return null; + } +} + +export const make = Effect.fn("makeVcsProjectConfig")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const findConfigPath = Effect.fn("VcsProjectConfig.findConfigPath")(function* (cwd: string) { + let current = cwd; + while (true) { + const candidate = path.join(current, ".t3code", "vcs.json"); + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return candidate; + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } + }); + + const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( + configPath: string, + ) { + const raw = yield* fileSystem.readFileString(configPath).pipe( + Effect.catch((error) => + Effect.logWarning("failed to read VCS project config", { + configPath, + error, + }).pipe(Effect.as(null)), + ), + ); + if (raw === null) { + return "auto" as const; + } + + const parsed = parseConfig(raw); + if (parsed === null) { + yield* Effect.logWarning("invalid VCS project config", { + configPath, + }); + return "auto" as const; + } + + return configuredKind(parsed); + }); + + const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( + "VcsProjectConfig.resolveKind", + )(function* (input) { + if (input.requestedKind !== undefined && input.requestedKind !== "auto") { + return input.requestedKind; + } + + const configPath = yield* findConfigPath(input.cwd); + if (configPath === null) { + return "auto"; + } + + return yield* readConfiguredKind(configPath); + }); + + return VcsProjectConfig.of({ + resolveKind, + }); +}); + +export const layer = Layer.effect(VcsProjectConfig, make()); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts new file mode 100644 index 00000000000..ba919a5f435 --- /dev/null +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -0,0 +1,97 @@ +import { assert, 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 { ChildProcessSpawner } from "effect/unstable/process"; + +import * as VcsDriver from "./VcsDriver.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; +import * as VcsProvisioningService from "./VcsProvisioningService.ts"; + +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +function makeDriver(calls: string[]): VcsDriver.VcsDriverShape { + return { + capabilities: { + kind: "git", + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native", + }, + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + detectRepository: () => Effect.succeed(null), + isInsideWorkTree: () => Effect.succeed(false), + listWorkspaceFiles: () => + Effect.succeed({ + paths: [], + truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + listRemotes: () => + Effect.succeed({ + remotes: [], + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + initRepository: (input) => + Effect.sync(() => { + calls.push(`${input.kind ?? "default"}:${input.cwd}`); + }), + }; +} + +it.effect("routes repository initialization through an explicit VCS driver kind", () => { + const calls: string[] = []; + const driver = makeDriver(calls); + const testLayer = VcsProvisioningService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), + }), + ), + ); + + return Effect.gen(function* () { + const provisioning = yield* VcsProvisioningService.VcsProvisioningService; + yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); + + assert.deepStrictEqual(calls, ["git:/repo"]); + }).pipe(Effect.provide(testLayer)); +}); + +it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { + const calls: string[] = []; + const driver = makeDriver(calls); + const testLayer = VcsProvisioningService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), + }), + ), + ); + + return Effect.gen(function* () { + const provisioning = yield* VcsProvisioningService.VcsProvisioningService; + yield* provisioning.initRepository({ cwd: "/repo" }); + + assert.deepStrictEqual(calls, ["default:/repo"]); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts new file mode 100644 index 00000000000..38006b4b603 --- /dev/null +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -0,0 +1,56 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + type VcsDriverKind, + type VcsError, + type VcsInitInput, + VcsUnsupportedOperationError, +} from "@t3tools/contracts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; + +export interface VcsProvisioningServiceShape { + readonly initRepository: (input: VcsInitInput) => Effect.Effect; +} + +export class VcsProvisioningService extends Context.Service< + VcsProvisioningService, + VcsProvisioningServiceShape +>()("t3/vcs/VcsProvisioningService") {} + +function resolveRequestedKind( + kind: VcsDriverKind | undefined, +): Effect.Effect { + if (kind === undefined) { + return Effect.succeed("git"); + } + if (kind === "unknown") { + return Effect.fail( + new VcsUnsupportedOperationError({ + operation: "VcsProvisioningService.resolveRequestedKind", + kind, + detail: "A concrete VCS driver kind is required for repository provisioning.", + }), + ); + } + return Effect.succeed(kind); +} + +export const make = Effect.fn("makeVcsProvisioningService")(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + + const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( + "VcsProvisioningService.initRepository", + )(function* (input) { + const kind = yield* resolveRequestedKind(input.kind); + const driver = yield* registry.get(kind); + return yield* driver.initRepository(input); + }); + + return VcsProvisioningService.of({ + initRepository, + }); +}); + +export const layer = Layer.effect(VcsProvisioningService, make()); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts similarity index 52% rename from apps/server/src/git/Layers/GitStatusBroadcaster.test.ts rename to apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 72a0c24e27b..d47dda29bf0 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,83 +1,88 @@ -import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; +import { assert, it, describe } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import type { - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusResult, - GitStatusStreamEvent, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { describe } from "vitest"; -import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; -import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; -import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; +import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; -const baseLocalStatus: GitStatusLocalResult = { +const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, - hostingProvider: { + sourceControlProvider: { kind: "github", name: "GitHub", baseUrl: "https://github.com", }, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/status-broadcast", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/status-broadcast", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }; -const baseRemoteStatus: GitStatusRemoteResult = { +const baseRemoteStatus: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 0, behindCount: 0, pr: null, }; -const baseStatus: GitStatusResult = { +const baseStatus: VcsStatusResult = { ...baseLocalStatus, ...baseRemoteStatus, }; function makeTestLayer(state: { - currentLocalStatus: GitStatusLocalResult; - currentRemoteStatus: GitStatusRemoteResult | null; + currentLocalStatus: VcsStatusLocalResult; + currentRemoteStatus: VcsStatusRemoteResult | null; localStatusCalls: number; remoteStatusCalls: number; localInvalidationCalls: number; remoteInvalidationCalls: number; }) { - const gitManager: GitManagerShape = { - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; + return VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + 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; + }), }), - remoteStatus: () => - Effect.sync(() => { - state.remoteStatusCalls += 1; - return state.currentRemoteStatus; - }), - status: () => Effect.die("status should not be called in this test"), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), - resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), - preparePullRequestThread: () => - Effect.die("preparePullRequestThread should not be called in this test"), - runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), - }; - - return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); + ), + ); } -describe("GitStatusBroadcasterLive", () => { - it.effect("reuses the cached git status across repeated reads", () => { +describe("VcsStatusBroadcaster", () => { + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -88,7 +93,7 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const first = yield* broadcaster.getStatus({ cwd: "/repo" }); const second = yield* broadcaster.getStatus({ cwd: "/repo" }); @@ -113,12 +118,12 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { ...baseLocalStatus, - branch: "feature/updated-status", + refName: "feature/updated-status", }; state.currentRemoteStatus = { ...baseRemoteStatus, @@ -154,12 +159,12 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { ...baseLocalStatus, - branch: "feature/local-only-refresh", + refName: "feature/local-only-refresh", hasWorkingTreeChanges: true, }; @@ -179,6 +184,67 @@ describe("GitStatusBroadcasterLive", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => { + const seenCwds: string[] = []; + 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( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + 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 fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-link-", + }); + const linkDir = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, linkDir); + const realPath = yield* fileSystem.realPath(realDir); + + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: linkDir }); + yield* broadcaster.getStatus({ cwd: realDir }); + + assert.deepStrictEqual(seenCwds, [realPath, realPath]); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("streams a local snapshot first and remote updates later", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -190,9 +256,9 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { if (event._tag === "snapshot") { return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); @@ -211,14 +277,62 @@ describe("GitStatusBroadcasterLive", () => { _tag: "snapshot", local: baseLocalStatus, remote: null, - } satisfies GitStatusStreamEvent); + } satisfies VcsStatusStreamEvent); assert.deepStrictEqual(remoteUpdated, { _tag: "remoteUpdated", remote: baseRemoteStatus, - } satisfies GitStatusStreamEvent); + } satisfies VcsStatusStreamEvent); }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("does not start automatic remote refreshes when disabled", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const snapshot = yield* Stream.runHead( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + ); + + assert.isTrue(Option.isSome(snapshot)); + assert.equal(state.remoteStatusCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), + 30_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(2, Duration.seconds(1))), + 60_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(3, Duration.seconds(1))), + 120_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.minutes(5))), + 300_000, + ); + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(20, Duration.seconds(1))), + 900_000, + ); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -230,9 +344,10 @@ describe("GitStatusBroadcasterLive", () => { }; let remoteInterruptedDeferred: Deferred.Deferred | null = null; let remoteStartedDeferred: Deferred.Deferred | null = null; - const testLayer = GitStatusBroadcasterLive.pipe( + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), Layer.provide( - Layer.succeed(GitManager, { + Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.sync(() => { state.localStatusCalls += 1; @@ -247,14 +362,13 @@ describe("GitStatusBroadcasterLive", () => { ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) : Effect.void, ), - Effect.andThen(Effect.never as Effect.Effect), + Effect.andThen(Effect.never as Effect.Effect), Effect.onInterrupt(() => remoteInterruptedDeferred ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) : Effect.void, ), ), - status: () => Effect.die("status should not be called in this test"), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -263,13 +377,7 @@ describe("GitStatusBroadcasterLive", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), - invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), - resolvePullRequest: () => - Effect.die("resolvePullRequest should not be called in this test"), - preparePullRequestThread: () => - Effect.die("preparePullRequestThread should not be called in this test"), - runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), - } satisfies GitManagerShape), + } satisfies Partial), ), ); @@ -279,9 +387,9 @@ describe("GitStatusBroadcasterLive", () => { remoteInterruptedDeferred = remoteInterrupted; remoteStartedDeferred = remoteStarted; - const broadcaster = yield* GitStatusBroadcaster; - const firstSnapshot = yield* Deferred.make(); - const secondSnapshot = yield* Deferred.make(); + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); const firstScope = yield* Scope.make(); const secondScope = yield* Scope.make(); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => @@ -302,11 +410,11 @@ describe("GitStatusBroadcasterLive", () => { assert.equal(state.remoteStatusCalls, 1); yield* Scope.close(firstScope, Exit.void); - assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); + assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); yield* Deferred.await(remoteInterrupted); - assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); + assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted))); }).pipe(Effect.provide(testLayer)); }); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts new file mode 100644 index 00000000000..40cdcf2c809 --- /dev/null +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -0,0 +1,396 @@ +import * as Context from "effect/Context"; +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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import type { + GitManagerServiceError, + VcsStatusInput, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, +} from "@t3tools/contracts"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; + +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; + +const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); +const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); +const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); + +interface VcsStatusChange { + readonly cwd: string; + readonly event: VcsStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedVcsStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + +interface StreamStatusOptions { + readonly automaticRemoteRefreshInterval?: Effect.Effect; +} + +export function remoteRefreshFailureDelay( + consecutiveFailures: number, + configuredInterval: Duration.Duration, +) { + const exponent = Math.max(0, consecutiveFailures - 1); + const backoffMs = + Duration.toMillis(VCS_STATUS_REFRESH_FAILURE_BASE_DELAY) * Math.pow(2, exponent); + const cappedBackoff = Duration.min( + Duration.millis(backoffMs), + VCS_STATUS_REFRESH_FAILURE_MAX_DELAY, + ); + return Duration.max(configuredInterval, cappedBackoff); +} + +export interface VcsStatusBroadcasterShape { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: VcsStatusInput, + options?: StreamStatusOptions, + ) => Stream.Stream; +} + +export class VcsStatusBroadcaster extends Context.Service< + VcsStatusBroadcaster, + VcsStatusBroadcasterShape +>()("t3/vcs/VcsStatusBroadcaster") {} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +const normalizeCwd = (cwd: string) => + Effect.service(FileSystem.FileSystem).pipe( + Effect.flatMap((fs) => fs.realPath(cwd)), + Effect.orElseSucceed(() => cwd), + ); + +export const layer = Layer.effect( + VcsStatusBroadcaster, + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const fs = yield* FileSystem.FileSystem; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); + + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( + cwd: string, + ) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, + }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } + + return local; + }, + ); + + const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( + function* ( + cwd: string, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }, + ); + + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( + cwd: string, + ) { + const remote = yield* workflow.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote); + }); + + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( + cwd: string, + ) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")( + function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(cwd); + }, + ); + + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + + const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( + "VcsStatusBroadcaster.getStatus", + )(function* (input) { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(cwd), + getOrLoadRemoteStatus(cwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }); + + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( + cwd: string, + ) { + yield* workflow.invalidateRemoteStatus(cwd); + const remote = yield* workflow.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshStatus", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + const [local, remote] = yield* Effect.all([ + refreshLocalStatus(cwd), + refreshRemoteStatus(cwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const makeRemoteRefreshLoop = ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + ) => { + return Effect.gen(function* () { + const consecutiveFailuresRef = yield* Ref.make(0); + const refreshRemoteStatusIfEnabled = Effect.gen(function* () { + const configuredInterval = yield* automaticRemoteRefreshInterval; + const activeInterval = Duration.isZero(configuredInterval) + ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL + : configuredInterval; + if (Duration.isZero(configuredInterval)) { + return activeInterval; + } + + const exit = yield* refreshRemoteStatus(cwd).pipe(Effect.exit); + if (Exit.isSuccess(exit)) { + yield* Ref.set(consecutiveFailuresRef, 0); + return activeInterval; + } + + const consecutiveFailures = yield* Ref.updateAndGet( + consecutiveFailuresRef, + (count) => count + 1, + ); + const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); + yield* Effect.logWarning("VCS remote status refresh failed", { + cwd, + detail: exit.cause.toString(), + consecutiveFailures, + nextDelayMs: Duration.toMillis(nextDelay), + }); + return nextDelay; + }); + + return yield* refreshRemoteStatusIfEnabled.pipe( + Effect.repeat( + Schedule.identity().pipe( + Schedule.addDelay((delay) => Effect.succeed(delay)), + ), + ), + Effect.asVoid, + ); + }); + }; + + const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + ) { + 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 makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextPollers] as const; + }), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( + cwd: string, + ) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [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; + } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(cwd); + const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; + yield* retainRemotePoller( + cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + ); + + const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + }); + }), +); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts new file mode 100644 index 00000000000..fd474283590 --- /dev/null +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -0,0 +1,169 @@ +import { assert, it, describe } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import type * as Scope from "effect/Scope"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +import type { VcsDriverKind } from "@t3tools/contracts"; +import * as VcsDriver from "../VcsDriver.ts"; + +function normalizePathForComparison(value: string): string { + return value.replaceAll("\\", "/"); +} + +export interface VcsDriverFixture { + readonly createRepo: (cwd: string) => Effect.Effect; + readonly writeFile: ( + cwd: string, + relativePath: string, + contents: string, + ) => Effect.Effect; + readonly trackFile?: (cwd: string, relativePath: string) => Effect.Effect; + readonly commit?: (cwd: string, message: string) => Effect.Effect; + readonly ignorePath: ( + cwd: string, + pattern: string, + ) => Effect.Effect; +} + +export interface VcsDriverContractSuiteInput { + readonly name: string; + readonly kind: VcsDriverKind; + readonly layer: Layer.Layer< + VcsDriver.VcsDriver | R | FileSystem.FileSystem | Path.Path, + E, + never + >; + readonly fixture: VcsDriverFixture; +} + +export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInput) { + const makeTmpDir = ( + prefix = `t3-${input.kind}-vcs-contract-`, + ): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + + it.layer(input.layer)(`${input.name} VCS driver contract`, (it) => { + describe("repository detection", () => { + it.effect("returns null outside a repository", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver.VcsDriver; + + assert.equal(yield* driver.detectRepository(cwd), null); + assert.equal(yield* driver.isInsideWorkTree(cwd), false); + }), + ); + + it.effect("detects repository identity inside a repository and nested directories", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver.VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); + const identity = yield* driver.detectRepository(cwd); + assert.equal(identity?.kind, input.kind); + assert.isTrue( + normalizePathForComparison(identity?.rootPath ?? "").endsWith( + normalizePathForComparison(cwd), + ), + ); + assert.equal(identity?.freshness.source, "live-local"); + assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); + assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); + assert.equal(yield* driver.isInsideWorkTree(cwd), true); + + const path = yield* Path.Path; + const nestedDir = path.join(cwd, "src"); + const nestedIdentity = yield* driver.detectRepository(nestedDir); + assert.equal(nestedIdentity?.rootPath, identity?.rootPath); + assert.equal(yield* driver.isInsideWorkTree(nestedDir), true); + }), + ); + }); + + describe("workspace files", () => { + it.effect("lists tracked and untracked non-ignored files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver.VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); + if (input.fixture.trackFile && input.fixture.commit) { + yield* input.fixture.trackFile(cwd, "tracked.ts"); + yield* input.fixture.commit(cwd, "Track file"); + } + yield* input.fixture.writeFile(cwd, "untracked.ts", "export const untracked = true;\n"); + + const result = yield* driver.listWorkspaceFiles(cwd); + + assert.include(result.paths, "tracked.ts"); + assert.include(result.paths, "untracked.ts"); + assert.equal(result.truncated, false); + assert.equal(result.freshness.source, "live-local"); + assert.isTrue(DateTime.isDateTime(result.freshness.observedAt)); + assert.isTrue(Option.isNone(result.freshness.expiresAt)); + }), + ); + + it.effect("excludes ignored files from workspace listing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver.VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.ignorePath(cwd, "*.log"); + yield* input.fixture.writeFile(cwd, "included.ts", "export const included = true;\n"); + yield* input.fixture.writeFile(cwd, "debug.log", "ignore me\n"); + yield* input.fixture.writeFile(cwd, "nested/error.log", "ignore me too\n"); + + const result = yield* driver.listWorkspaceFiles(cwd); + + assert.include(result.paths, "included.ts"); + assert.notInclude(result.paths, "debug.log"); + assert.notInclude(result.paths, "nested/error.log"); + }), + ); + }); + + describe("ignored path filtering", () => { + it.effect("filters ignored paths", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver.VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.ignorePath(cwd, "*.log"); + + const result = yield* driver.filterIgnoredPaths(cwd, [ + "keep.ts", + "debug.log", + "nested/error.log", + ]); + + assert.deepStrictEqual(result, ["keep.ts"]); + }), + ); + + it.effect("returns empty input unchanged", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver.VcsDriver; + + yield* input.fixture.createRepo(cwd); + + assert.deepStrictEqual(yield* driver.filterIgnoredPaths(cwd, []), []); + }), + ); + }); + }); +} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 09f6905ce98..84ea5c51937 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -1,12 +1,17 @@ +// @effect-diagnostics nodeBuiltinImport:off import fsPromises from "node:fs/promises"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, afterEach, describe, expect, vi } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -14,7 +19,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -25,12 +31,11 @@ const TestLayer = Layer.empty.pipe( const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { const fileSystem = yield* FileSystem.FileSystem; - const gitCore = yield* GitCore; const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: opts?.prefix ?? "t3code-workspace-entries-", }); if (opts?.git) { - yield* gitCore.initRepo({ cwd: dir }); + yield* git(dir, ["init"]); } return dir; }); @@ -51,9 +56,10 @@ function writeTextFile( const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ operation: "WorkspaceEntries.test.git", + command: "git", cwd, args, ...(env ? { env } : {}), @@ -68,6 +74,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb return yield* workspaceEntries.search(input); }); +const appendSeparator = (input: string) => + input.endsWith("/") || input.endsWith("\\") + ? input + : `${input}${process.platform === "win32" ? "\\" : "/"}`; + it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { afterEach(() => { vi.restoreAllMocks(); @@ -216,25 +227,37 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { yield* writeTextFile(cwd, "src/components/Composer.tsx"); let rootReadCount = 0; + let releaseRootRead: (() => void) | undefined; + const rootReadGate = new Promise((resolve) => { + releaseRootRead = resolve; + }); const originalReaddir = fsPromises.readdir.bind(fsPromises); vi.spyOn(fsPromises, "readdir").mockImplementation((async ( ...args: Parameters ) => { if (args[0] === cwd) { rootReadCount += 1; - await new Promise((resolve) => setTimeout(resolve, 20)); + await rootReadGate; } return originalReaddir(...args); }) as typeof fsPromises.readdir); - yield* Effect.all( + const searches = yield* Effect.all( [ searchWorkspaceEntries({ cwd, query: "", limit: 100 }), searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), ], { concurrency: "unbounded" }, - ); + ).pipe(Effect.forkScoped); + for (let attempt = 0; attempt < 50; attempt += 1) { + if (rootReadCount > 0) { + break; + } + yield* Effect.yieldNow; + } + releaseRootRead?.(); + yield* Fiber.join(searches); expect(rootReadCount).toBe(1); }), @@ -251,6 +274,10 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { let activeReads = 0; let peakReads = 0; + let releaseReads: (() => void) | undefined; + const readsGate = new Promise((resolve) => { + releaseReads = resolve; + }); const originalReaddir = fsPromises.readdir.bind(fsPromises); vi.spyOn(fsPromises, "readdir").mockImplementation((async ( ...args: Parameters @@ -259,7 +286,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { if (typeof target === "string" && target.startsWith(cwd)) { activeReads += 1; peakReads = Math.max(peakReads, activeReads); - await new Promise((resolve) => setTimeout(resolve, 4)); + await readsGate; try { return await originalReaddir(...args); } finally { @@ -269,10 +296,101 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { return originalReaddir(...args); }) as typeof fsPromises.readdir); - yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 }); + const search = yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 }).pipe( + Effect.forkScoped, + ); + for (let attempt = 0; attempt < 50; attempt += 1) { + if (activeReads > 0) { + break; + } + yield* Effect.yieldNow; + } + releaseReads?.(); + yield* Fiber.join(search); expect(peakReads).toBeLessThanOrEqual(32); }), ); }); + + describe("browse", () => { + it.effect("returns matching directories and excludes files", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); + yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); + yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); + yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n"); + + const result = yield* workspaceEntries.browse({ + partialPath: path.join(cwd, "alp"), + }); + + expect(result).toEqual({ + parentPath: cwd, + entries: [ + { name: "alpha", fullPath: path.join(cwd, "alpha") }, + { name: "alpine", fullPath: path.join(cwd, "alpine") }, + ], + }); + }), + ); + + it.effect("shows dot directories in directory mode and hidden-prefix mode", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); + yield* writeTextFile(cwd, ".config/settings.json", "{}"); + yield* writeTextFile(cwd, "config/settings.json", "{}"); + + const directoryResult = yield* workspaceEntries.browse({ + partialPath: appendSeparator(cwd), + }); + const hiddenPrefixResult = yield* workspaceEntries.browse({ + partialPath: `${appendSeparator(cwd)}.c`, + }); + + expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); + expect(hiddenPrefixResult).toEqual({ + parentPath: cwd, + entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }], + }); + }), + ); + + it.effect("supports relative paths when cwd is provided", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); + yield* writeTextFile(cwd, "packages/pkg.json", "{}"); + + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./pack", + }); + + expect(result).toEqual({ + parentPath: cwd, + entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], + }); + }), + ); + + it.effect("rejects relative paths without cwd", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + + const error = yield* workspaceEntries + .browse({ + partialPath: "./src", + }) + .pipe(Effect.flip); + + expect(error.detail).toBe("Relative filesystem browse paths require a current project."); + }), + ); + }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index c4d3c3c81f3..0c0ab638207 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,9 +1,18 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as OS from "node:os"; import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; -import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; +import * as Cache from "effect/Cache"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; -import { type ProjectEntry } from "@t3tools/contracts"; +import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { insertRankedSearchResult, normalizeSearchQuery, @@ -11,9 +20,10 @@ import { type RankedSearchResult, } from "@t3tools/shared/searchRanking"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; import { WorkspaceEntries, + WorkspaceEntriesBrowseError, WorkspaceEntriesError, type WorkspaceEntriesShape, } from "../Services/WorkspaceEntries.ts"; @@ -52,6 +62,16 @@ function toPosixPath(input: string): string { return input.replaceAll("\\", "/"); } +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(OS.homedir(), input.slice(2)); + } + return input; +} + function parentPathOf(input: string): string | undefined { const separatorIndex = input.lastIndexOf("/"); if (separatorIndex === -1) { @@ -129,40 +149,71 @@ function directoryAncestorsOf(relativePath: string): string[] { return directories; } +const resolveBrowseTarget = ( + input: FilesystemBrowseInput, + pathService: Path.Path, +): Effect.Effect => + Effect.gen(function* () { + if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Windows-style paths are only supported on Windows.", + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return pathService.resolve(expandHomePath(input.partialPath, pathService)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Relative filesystem browse paths require a current project.", + }); + } + + return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); + }); + export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; - const gitOption = yield* Effect.serviceOption(GitCore); + const vcsRegistry = yield* VcsDriverRegistry; const workspacePaths = yield* WorkspacePaths; - const isInsideGitWorkTree = (cwd: string): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), - onNone: () => Effect.succeed(false), - }); + const isInsideVcsWorkTree = (cwd: string): Effect.Effect => + vcsRegistry.detect({ cwd }).pipe( + Effect.map((handle) => handle !== null), + Effect.catch(() => Effect.succeed(false)), + ); - const filterGitIgnoredPaths = ( + const filterVcsIgnoredPaths = ( cwd: string, relativePaths: string[], ): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => - git.filterIgnoredPaths(cwd, relativePaths).pipe( - Effect.map((paths) => [...paths]), - Effect.catch(() => Effect.succeed(relativePaths)), - ), - onNone: () => Effect.succeed(relativePaths), - }); - - const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( + vcsRegistry.detect({ cwd }).pipe( + Effect.flatMap((handle) => + handle + ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( + Effect.map((paths) => [...paths]), + Effect.catch(() => Effect.succeed(relativePaths)), + ) + : Effect.succeed(relativePaths), + ), + Effect.catch(() => Effect.succeed(relativePaths)), + ); + + const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( function* (cwd: string) { - if (Option.isNone(gitOption)) { - return null; - } - if (!(yield* isInsideGitWorkTree(cwd))) { + const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.catch(() => Effect.succeed(null))); + if (!vcs) { return null; } - const listedFiles = yield* gitOption.value + const listedFiles = yield* vcs.driver .listWorkspaceFiles(cwd) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -173,7 +224,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const listedPaths = [...listedFiles.paths] .map((entry) => toPosixPath(entry)) .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); - const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths); + const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( + Effect.map((paths) => [...paths]), + Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), + ); const directorySet = new Set(); for (const filePath of filePaths) { @@ -205,9 +259,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ) .map(toSearchableWorkspaceEntry); + const now = yield* DateTime.now; const entries = [...directoryEntries, ...fileEntries]; return { - scannedAt: Date.now(), + scannedAt: now.epochMilliseconds, entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, }; @@ -245,7 +300,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndexFromFilesystem = Effect.fn( "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd); + const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); let pendingDirectories: string[] = [""]; const entries: SearchableWorkspaceEntry[] = []; @@ -293,7 +348,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { candidateEntries.map((entry) => entry.relativePath), ); const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths)) + ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) : null; for (const candidateEntries of candidateEntriesByDirectory) { @@ -325,8 +380,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { } } + const now = yield* DateTime.now; return { - scannedAt: Date.now(), + scannedAt: now.epochMilliseconds, entries, truncated, }; @@ -335,9 +391,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( cwd: string, ): Effect.fn.Return { - const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd); - if (gitIndexed) { - return gitIndexed; + const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); + if (vcsIndexed) { + return vcsIndexed; } return yield* buildWorkspaceIndexFromFilesystem(cwd); }); @@ -379,6 +435,46 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }, ); + const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.browse.readDirectory", + detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + + return { + parentPath, + entries: dirents + .filter( + (dirent) => + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")), + ) + .map((dirent) => ({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); @@ -415,6 +511,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ); return { + browse, invalidate, search, } satisfies WorkspaceEntriesShape; diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fcfd13c912e..e748a27a58a 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -1,9 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { ServerConfig } from "../../config.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; @@ -19,7 +23,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 84e5d9c6d12..9f53ade1bb9 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -1,4 +1,7 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { WorkspaceFileSystem, diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts index d02a5929d27..0a9252a7def 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -58,6 +61,24 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); + it.effect("creates missing directories when createIfMissing is enabled", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + const missingPath = path.join(cwd, "nested", "new-project"); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { + createIfMissing: true, + }); + const stat = yield* fileSystem.stat(resolved); + + expect(resolved).toBe(missingPath); + expect(stat.type).toBe("Directory"); + }), + ); + it.effect("rejects file paths", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index fa7a90cf07d..9dd33aaac7d 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -1,9 +1,13 @@ import * as OS from "node:os"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { WorkspacePaths, WorkspacePathOutsideRootError, + WorkspaceRootCreateFailedError, WorkspaceRootNotDirectoryError, WorkspaceRootNotExistsError, type WorkspacePathsShape, @@ -29,11 +33,25 @@ export const makeWorkspacePaths = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", - )(function* (workspaceRoot) { + )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - const workspaceStat = yield* fileSystem + let workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat && options?.createIfMissing) { + yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( + Effect.mapError( + () => + new WorkspaceRootCreateFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + }), + ), + ); + workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ workspaceRoot, diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts index c0011a9dc9a..a5d7ed8c082 100644 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -6,10 +6,16 @@ * * @module WorkspaceEntries */ -import { Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; -import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts"; +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( "WorkspaceEntriesError", @@ -21,11 +27,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesBrowseError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + /** * WorkspaceEntriesShape - Service API for workspace entry search and cache * invalidation. */ export interface WorkspaceEntriesShape { + /** + * Browse matching directories for the provided partial path. + */ + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + /** * Search indexed workspace entries for files and directories matching the * provided query. diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index dc6cc6e9d85..b448a8ab505 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -6,8 +6,9 @@ * * @module WorkspaceFileSystem */ -import { Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts index 86cef907359..7c57ca19bd2 100644 --- a/apps/server/src/workspace/Services/WorkspacePaths.ts +++ b/apps/server/src/workspace/Services/WorkspacePaths.ts @@ -6,8 +6,9 @@ * * @module WorkspacePaths */ -import { Schema, Context } from "effect"; -import type { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( "WorkspaceRootNotExistsError", @@ -21,6 +22,18 @@ export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( + "WorkspaceRootCreateFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; + } +} + export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { @@ -47,6 +60,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass Effect.Effect; + options?: { readonly createIfMissing?: boolean }, + ) => Effect.Effect< + string, + WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError + >; /** * Resolve a relative path within a validated workspace root. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3ef4a864697..e99672161c9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,5 +1,15 @@ -import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; +import * as Cause from "effect/Cause"; +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 Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { + DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, type AuthAccessStreamEvent, AuthSessionId, CommandId, @@ -9,6 +19,7 @@ import { type GitManagerServiceError, OrchestrationDispatchCommandError, type OrchestrationEvent, + type OrchestrationShellStreamEvent, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -16,6 +27,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, + FilesystemBrowseError, ThreadId, type TerminalEvent, WS_METHODS, @@ -25,42 +37,85 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; -import { ServerConfig } from "./config"; -import { GitCore } from "./git/Services/GitCore"; -import { GitManager } from "./git/Services/GitManager"; -import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; -import { Keybindings } from "./keybindings"; -import { Open, resolveAvailableEditors } from "./open"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; +import { ServerConfig } from "./config.ts"; +import { Keybindings } from "./keybindings.ts"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; +import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { observeRpcEffect, observeRpcStream, observeRpcStreamEffect, -} from "./observability/RpcInstrumentation"; -import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerRuntimeStartup } from "./serverRuntimeStartup"; -import { ServerSettingsService } from "./serverSettings"; -import { TerminalManager } from "./terminal/Services/Manager"; -import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; -import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; -import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +} from "./observability/RpcInstrumentation.ts"; +import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; +import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; +import { TerminalManager } from "./terminal/Services/Manager.ts"; +import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; +import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; +import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; +import { GitWorkflowService } from "./git/GitWorkflowService.ts"; +import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; +import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; +import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; +import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; +import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitLabCli from "./sourceControl/GitLabCli.ts"; +import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; import { BootstrapCredentialService, type BootstrapCredentialChange, -} from "./auth/Services/BootstrapCredentialService"; +} from "./auth/Services/BootstrapCredentialService.ts"; import { SessionCredentialService, type SessionCredentialChange, -} from "./auth/Services/SessionCredentialService"; -import { respondToAuthError } from "./auth/http"; +} from "./auth/Services/SessionCredentialService.ts"; +import { respondToAuthError } from "./auth/http.ts"; +const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); +const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< + OrchestrationEvent, + { + type: + | "thread.message-sent" + | "thread.proposed-plan-upserted" + | "thread.activity-appended" + | "thread.turn-diff-completed" + | "thread.reverted" + | "thread.session-set"; + } +> { + return ( + event.type === "thread.message-sent" || + event.type === "thread.proposed-plan-upserted" || + event.type === "thread.activity-appended" || + event.type === "thread.turn-diff-completed" || + event.type === "thread.reverted" || + event.type === "thread.session-set" + ); +} + +const PROVIDER_STATUS_DEBOUNCE_MS = 200; function toAuthAccessStreamEvent( change: BootstrapCredentialChange | SessionCredentialChange, @@ -109,12 +164,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const orchestrationEngine = yield* OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery; const keybindings = yield* Keybindings; - const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitCore; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const externalLauncher = yield* ExternalLauncher.ExternalLauncher; + const gitWorkflow = yield* GitWorkflowService; + const vcsProvisioning = yield* VcsProvisioningService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; + const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; @@ -125,8 +181,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; const serverAuth = yield* ServerAuth; + const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; + const automaticGitFetchInterval = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.automaticGitFetchInterval), + Effect.catch((cause) => + Effect.logWarning("Failed to read automatic Git fetch interval setting", { + detail: cause.message, + }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), + ), + ); + const sourceControlRepositories = yield* SourceControlRepositoryService; const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; + const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const serverCommandId = (tag: string) => CommandId.make(`server:${tag}:${crypto.randomUUID()}`); @@ -161,7 +229,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }); const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => - Schema.is(OrchestrationDispatchCommandError)(cause) + isOrchestrationDispatchCommandError(cause) ? cause : new OrchestrationDispatchCommandError({ message: cause instanceof Error ? cause.message : fallbackMessage, @@ -170,7 +238,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { const error = Cause.squash(cause); - return Schema.is(OrchestrationDispatchCommandError)(error) + return isOrchestrationDispatchCommandError(error) ? error : new OrchestrationDispatchCommandError({ message: @@ -197,9 +265,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => return Effect.gen(function* () { const workspaceRoot = event.payload.workspaceRoot ?? - (yield* orchestrationEngine.getReadModel()).projects.find( - (project) => project.id === event.payload.projectId, - )?.workspaceRoot ?? + Option.match( + yield* projectionSnapshotQuery.getProjectShellById(event.payload.projectId), + { + onNone: () => null, + onSome: (project) => project.workspaceRoot, + }, + ) ?? null; if (workspaceRoot === null) { return event; @@ -213,7 +285,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => repositoryIdentity, }, } satisfies OrchestrationEvent; - }); + }).pipe(Effect.catch(() => Effect.succeed(event))); default: return Effect.succeed(event); } @@ -222,6 +294,69 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const enrichOrchestrationEvents = (events: ReadonlyArray) => Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const toShellStreamEvent = ( + event: OrchestrationEvent, + ): Effect.Effect, never, never> => { + switch (event.type) { + case "project.created": + case "project.meta-updated": + return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( + Effect.map((project) => + Option.map(project, (nextProject) => ({ + kind: "project-upserted" as const, + sequence: event.sequence, + project: nextProject, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + case "project.deleted": + return Effect.succeed( + Option.some({ + kind: "project-removed" as const, + sequence: event.sequence, + projectId: event.payload.projectId, + }), + ); + case "thread.deleted": + case "thread.archived": + return Effect.succeed( + Option.some({ + kind: "thread-removed" as const, + sequence: event.sequence, + threadId: event.payload.threadId, + }), + ); + case "thread.unarchived": + return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + default: + if (event.aggregateKind !== "thread") { + return Effect.succeed(Option.none()); + } + return projectionSnapshotQuery + .getThreadShellById(ThreadId.make(event.aggregateId)) + .pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + } + }; + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -279,83 +414,86 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => readonly scriptId: string; readonly scriptName: string; readonly terminalId: string; - }) => { - const payload = { - scriptId: input.scriptId, - scriptName: input.scriptName, - terminalId: input.terminalId, - worktreePath: input.worktreePath, - }; - return Effect.all([ - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", - createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: new Date().toISOString(), - payload, - tone: "info", - }), - ]).pipe( - Effect.asVoid, - Effect.catch((error) => - Effect.logWarning( - "bootstrap turn start launched setup script but failed to record setup activity", - { - threadId: command.threadId, - worktreePath: input.worktreePath, - scriptId: input.scriptId, - terminalId: input.terminalId, - detail: error.message, - }, + }) => + Effect.gen(function* () { + const startedAt = yield* nowIso; + const payload = { + scriptId: input.scriptId, + scriptName: input.scriptName, + terminalId: input.terminalId, + worktreePath: input.worktreePath, + }; + yield* Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: input.requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: startedAt, + payload, + tone: "info", + }), + ]).pipe( + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start launched setup script but failed to record setup activity", + { + threadId: command.threadId, + worktreePath: input.worktreePath, + scriptId: input.scriptId, + terminalId: input.terminalId, + detail: error.message, + }, + ), ), - ), - ); - }; + ); + }); const runSetupProgram = () => - bootstrap?.runSetupScript && targetWorktreePath - ? (() => { - const worktreePath = targetWorktreePath; - const requestedAt = new Date().toISOString(); - return projectSetupScriptRunner - .runForThread({ - threadId: command.threadId, - ...(targetProjectId ? { projectId: targetProjectId } : {}), - ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), - worktreePath, - }) - .pipe( - Effect.matchEffect({ - onFailure: (error) => - recordSetupScriptLaunchFailure({ - error, - requestedAt, - worktreePath, - }), - onSuccess: (setupResult) => { - if (setupResult.status !== "started") { - return Effect.void; - } - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }); - }, + Effect.gen(function* () { + if (!bootstrap?.runSetupScript || !targetWorktreePath) { + return; + } + const worktreePath = targetWorktreePath; + const requestedAt = yield* nowIso; + yield* projectSetupScriptRunner + .runForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + recordSetupScriptLaunchFailure({ + error, + requestedAt, + worktreePath, }), - ); - })() - : Effect.void; + onSuccess: (setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + return recordSetupScriptStarted({ + requestedAt, + worktreePath, + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + }); + }, + }), + ); + }); const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { @@ -376,10 +514,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => } if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ + const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, - branch: bootstrap.prepareWorktree.baseBranch, - newBranch: bootstrap.prepareWorktree.branch, + refName: bootstrap.prepareWorktree.baseBranch, + newRefName: bootstrap.prepareWorktree.branch, path: null, }); targetWorktreePath = worktree.worktree.path; @@ -387,9 +525,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => type: "thread.meta.update", commandId: serverCommandId("bootstrap-thread-meta-update"), threadId: command.threadId, - branch: worktree.worktree.branch, + branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); + yield* refreshGitStatus(targetWorktreePath); } yield* runSetupProgram(); @@ -434,7 +573,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; - const settings = yield* serverSettings.getSettings; + const settings = redactServerSettingsForClient(yield* serverSettings.getSettings); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -446,7 +585,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: resolveAvailableEditors(), + availableEditors: ExternalLauncher.resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -462,32 +601,55 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }); const refreshGitStatus = (cwd: string) => - gitStatusBroadcaster + vcsStatusBroadcaster .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ - [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getSnapshot, - projectionSnapshotQuery.getSnapshot().pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); + const shouldStopSessionAfterArchive = + normalizedCommand.type === "thread.archive" + ? yield* projectionSnapshotQuery + .getThreadShellById(normalizedCommand.threadId) + .pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }), + ), + Effect.catch(() => Effect.succeed(false)), + ) + : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { + if (shouldStopSessionAfterArchive) { + yield* Effect.gen(function* () { + const stopCommand = yield* normalizeDispatchCommand({ + type: "thread.session.stop", + commandId: CommandId.make( + `session-stop-for-archive:${normalizedCommand.commandId}`, + ), + threadId: normalizedCommand.threadId, + createdAt: yield* nowIso, + }); + + yield* dispatchNormalizedCommand(stopCommand); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider session during archive", { + threadId: normalizedCommand.threadId, + cause, + }), + ), + ); + } + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( Effect.catch((error) => Effect.logWarning("failed to close thread terminals after archive", { @@ -500,7 +662,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => return result; }).pipe( Effect.mapError((cause) => - Schema.is(OrchestrationDispatchCommandError)(cause) + isOrchestrationDispatchCommandError(cause) ? cause : new OrchestrationDispatchCommandError({ message: "Failed to dispatch orchestration command", @@ -561,65 +723,112 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "orchestration" }, ), - [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) => observeRpcStreamEffect( - WS_METHODS.subscribeOrchestrationDomainEvents, + ORCHESTRATION_WS_METHODS.subscribeShell, Effect.gen(function* () { - const snapshot = yield* orchestrationEngine.getReadModel(); - const fromSequenceExclusive = snapshot.snapshotSequence; - const replayEvents: Array = yield* Stream.runCollect( - orchestrationEngine.readEvents(fromSequenceExclusive), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.catch(() => Effect.succeed([] as Array)), + const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.tapError((cause) => + Effect.logError("orchestration shell snapshot load failed", { cause }), + ), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration shell snapshot", + cause, + }), + ), ); - const replayStream = Stream.fromIterable(replayEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(enrichProjectEvent), + Stream.mapEffect(toShellStreamEvent), + Stream.flatMap((event) => + Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + ), ); - const source = Stream.merge(replayStream, liveStream); - type SequenceState = { - readonly nextSequence: number; - readonly pendingBySequence: Map; - }; - const state = yield* Ref.make({ - nextSequence: fromSequenceExclusive + 1, - pendingBySequence: new Map(), - }); - return source.pipe( - Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot, + }), + liveStream, + ); + }), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, + projectionSnapshotQuery.getArchivedShellSnapshot().pipe( + Effect.tapError((cause) => + Effect.logError("orchestration archived shell snapshot load failed", { cause }), + ), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load archived orchestration shell snapshot", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => + observeRpcStreamEffect( + ORCHESTRATION_WS_METHODS.subscribeThread, + Effect.gen(function* () { + const [threadDetail, snapshotSequence] = yield* Effect.all([ + projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load thread ${input.threadId}`, + cause, + }), + ), + ), + projectionSnapshotQuery.getSnapshotSequence().pipe( + Effect.map(({ snapshotSequence }) => snapshotSequence), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot sequence", + cause, + }), + ), + ), + ]); - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); - - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } + if (Option.isNone(threadDetail)) { + return yield* new OrchestrationGetSnapshotError({ + message: `Thread ${input.threadId} was not found`, + cause: input.threadId, + }); + } - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, - ), + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === input.threadId && + isThreadDetailEvent(event), ), - Stream.flatMap((events) => Stream.fromIterable(events)), + Stream.map((event) => ({ + kind: "event" as const, + event, + })), + ); + + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot: { + snapshotSequence, + thread: threadDetail.value, + }, + }), + liveStream, ); }), { "rpc.aggregate": "orchestration" }, @@ -628,12 +837,23 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), - [WS_METHODS.serverRefreshProviders]: (_input) => + [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), + (input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh() + ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverUpdateProvider]: (input) => + observeRpcEffect( + WS_METHODS.serverUpdateProvider, + providerMaintenanceRunner.updateProvider(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, @@ -643,14 +863,92 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverRemoveKeybinding]: (rule) => + observeRpcEffect( + WS_METHODS.serverRemoveKeybinding, + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverGetSettings]: (_input) => - observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { + observeRpcEffect( + WS_METHODS.serverGetSettings, + serverSettings.getSettings.pipe(Effect.map(redactServerSettingsForClient)), + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverUpdateSettings]: ({ patch }) => + observeRpcEffect( + WS_METHODS.serverUpdateSettings, + serverSettings.updateSettings(patch).pipe(Effect.map(redactServerSettingsForClient)), + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverDiscoverSourceControl]: (_input) => + observeRpcEffect( + WS_METHODS.serverDiscoverSourceControl, + sourceControlDiscovery.discover, + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverGetTraceDiagnostics]: (_input) => + observeRpcEffect( + WS_METHODS.serverGetTraceDiagnostics, + TraceDiagnostics.readTraceDiagnostics({ + traceFilePath: config.serverTracePath, + maxFiles: config.traceMaxFiles, + }), + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverGetProcessDiagnostics]: (_input) => + observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), - [WS_METHODS.serverUpdateSettings]: ({ patch }) => - observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { + [WS_METHODS.serverGetProcessResourceHistory]: (input) => + observeRpcEffect( + WS_METHODS.serverGetProcessResourceHistory, + processResourceMonitor.readHistory(input), + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverSignalProcess]: (input) => + observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.sourceControlLookupRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlLookupRepository, + sourceControlRepositories.lookupRepository(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlCloneRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlCloneRepository, + sourceControlRepositories.cloneRepository(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlPublishRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlPublishRepository, + sourceControlRepositories + .publishRepository(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { + "rpc.aggregate": "source-control", + }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -670,7 +968,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => WS_METHODS.projectsWriteFile, workspaceFileSystem.writeFile(input).pipe( Effect.mapError((cause) => { - const message = Schema.is(WorkspacePathOutsideRootError)(cause) + const message = isWorkspacePathOutsideRootError(cause) ? "Workspace file path must stay within the project root." : "Failed to write workspace file"; return new ProjectWriteFileError({ @@ -682,29 +980,45 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => { "rpc.aggregate": "workspace" }, ), [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { + observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", }), - [WS_METHODS.subscribeGitStatus]: (input) => + [WS_METHODS.filesystemBrowse]: (input) => + observeRpcEffect( + WS_METHODS.filesystemBrowse, + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + message: cause.detail, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( - WS_METHODS.subscribeGitStatus, - gitStatusBroadcaster.streamStatus(input), + WS_METHODS.subscribeVcsStatus, + vcsStatusBroadcaster.streamStatus(input, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }), { - "rpc.aggregate": "git", + "rpc.aggregate": "vcs", }, ), - [WS_METHODS.gitRefreshStatus]: (input) => + [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( - WS_METHODS.gitRefreshStatus, - gitStatusBroadcaster.refreshStatus(input.cwd), + WS_METHODS.vcsRefreshStatus, + vcsStatusBroadcaster.refreshStatus(input.cwd), { - "rpc.aggregate": "git", + "rpc.aggregate": "vcs", }, ), - [WS_METHODS.gitPull]: (input) => + [WS_METHODS.vcsPull]: (input) => observeRpcEffect( - WS_METHODS.gitPull, - git.pullCurrentBranch(input.cwd).pipe( + WS_METHODS.vcsPull, + gitWorkflow.pullCurrentBranch(input.cwd).pipe( Effect.matchCauseEffect({ onFailure: (cause) => Effect.failCause(cause), onSuccess: (result) => @@ -717,7 +1031,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitManager + gitWorkflow .runStackedAction(input, { actionId: input.actionId, progressReporter: { @@ -734,55 +1048,59 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), ), ), - { "rpc.aggregate": "git" }, + { "rpc.aggregate": "vcs" }, ), [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitResolvePullRequest, + gitWorkflow.resolvePullRequest(input), + { + "rpc.aggregate": "git", + }, + ), [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitManager + gitWorkflow .preparePullRequestThread(input) .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), - [WS_METHODS.gitListBranches]: (input) => - observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { - "rpc.aggregate": "git", + [WS_METHODS.vcsListRefs]: (input) => + observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { + "rpc.aggregate": "vcs", }), - [WS_METHODS.gitCreateWorktree]: (input) => + [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( - WS_METHODS.gitCreateWorktree, - git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCreateWorktree, + gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitRemoveWorktree]: (input) => + [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( - WS_METHODS.gitRemoveWorktree, - git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsRemoveWorktree, + gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitCreateBranch]: (input) => + [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( - WS_METHODS.gitCreateBranch, - git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCreateRef, + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitCheckout]: (input) => + [WS_METHODS.vcsSwitchRef]: (input) => observeRpcEffect( - WS_METHODS.gitCheckout, - Effect.scoped(git.checkoutBranch(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), - ), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsSwitchRef, + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitInit]: (input) => + [WS_METHODS.vcsInit]: (input) => observeRpcEffect( - WS_METHODS.gitInit, - git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsInit, + vcsProvisioning + .initRepository(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { @@ -828,6 +1146,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => version: 1 as const, type: "keybindingsUpdated" as const, payload: { + keybindings: event.keybindings, issues: event.issues, }, })), @@ -838,8 +1157,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => type: "providerStatuses" as const, payload: { providers }, })), + Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( + Stream.map((settings) => redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, @@ -847,13 +1168,22 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => })), ); + yield* providerRegistry + .refresh() + .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); + + const liveUpdates = Stream.merge( + keybindingsUpdates, + Stream.merge(providerStatuses, settingsUpdates), + ); + return Stream.concat( Stream.make({ version: 1 as const, type: "snapshot" as const, config: yield* loadServerConfig, }), - Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), + liveUpdates, ); }), { "rpc.aggregate": "server" }, @@ -920,14 +1250,34 @@ export const websocketRpcRouteLayer = Layer.unwrap( const sessions = yield* SessionCredentialService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - spanPrefix: "ws.rpc", - spanAttributes: { - "rpc.transport": "websocket", - "rpc.system": "effect-rpc", - }, + disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + makeWsRpcLayer(session.sessionId).pipe( + Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide( + SourceControlDiscoveryLayer.layer.pipe( + Layer.provide( + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ), + ), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provide( + VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), + ), + ), + ), + Layer.provide(VcsProcess.layer), + ), + ), + ), ), ); return yield* Effect.acquireUseRelease( diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 07d52467f51..b86bbf1f16c 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,23 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "bun"], - "lib": ["ES2023", "esnext.disposable"], - "noEmit": true, - "allowImportingTsExtensions": true, - "plugins": [ - { - "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node"], - "diagnosticSeverity": { - "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", - "instanceOfSchema": "warning", - "deterministicKeys": "warning", - "preferSchemaOverJson": "off", - "globalErrorInEffectFailure": "off" - } - } - ] + "lib": ["ESNext", "esnext.disposable"] }, "include": ["src", "tsdown.config.ts", "scripts", "integration", "../../scripts/lib"] } diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index f11dd37869b..8f1cfc2136f 100644 --- a/apps/server/tsdown.config.ts +++ b/apps/server/tsdown.config.ts @@ -1,15 +1,13 @@ import { defineConfig } from "tsdown"; +const internalPackagePrefixes = ["@t3tools/", "effect-acp", "effect-codex-app-server"]; + export default defineConfig({ entry: ["src/bin.ts"], - format: ["esm", "cjs"], - checks: { - legacyCjs: false, - }, outDir: "dist", sourcemap: true, clean: true, - noExternal: (id) => id.startsWith("@t3tools/"), + noExternal: (id) => internalPackagePrefixes.some((prefix) => id.startsWith(prefix)), inlineOnly: false, banner: { js: "#!/usr/bin/env node\n", diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 1c5b2f0d38d..660d69423d9 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -1,6 +1,6 @@ import { defineConfig, mergeConfig } from "vitest/config"; -import baseConfig from "../../vitest.config"; +import baseConfig from "../../vitest.config.ts"; export default mergeConfig( baseConfig, diff --git a/apps/web/index.html b/apps/web/index.html index 9f0329b6020..88e1c8b4f23 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,10 @@ - + diff --git a/apps/web/package.json b/apps/web/package.json index a447b3e0efa..03f77c3aba8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,12 +1,11 @@ { "name": "@t3tools/web", - "version": "0.0.17", + "version": "0.0.24", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", - "prepare": "effect-language-service patch", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", @@ -14,30 +13,30 @@ "test:browser:install": "playwright install --with-deps chromium" }, "dependencies": { - "@base-ui/react": "^1.2.0", + "@base-ui/react": "^1.4.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", + "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", - "@pierre/diffs": "^1.1.0-beta.16", + "@pierre/diffs": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "19.2.6", + "react-dom": "19.2.6", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", @@ -49,11 +48,12 @@ "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/babel__core": "^7.20.5", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", + "@types/react": "~19.2.14", + "@types/react-dom": "~19.2.3", + "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", - "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "babel-plugin-react-compiler": "1.0.0", "msw": "2.12.11", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index f86d7c5a792..e6f08f147ca 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -353,6 +353,34 @@ describe("resolveInitialServerAuthGateState", () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse( + { + error: "Invalid bootstrap credential.", + }, + { + status: 401, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const { submitServerAuthCredential } = await import("./environments/primary"); + + await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( + "Invalid pairing token. Check the token and try again.", + ); + expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/bootstrap", { + body: JSON.stringify({ credential: "bad-token" }), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { vi.useFakeTimers(); const fetchMock = vi @@ -424,7 +452,7 @@ describe("resolveInitialServerAuthGateState", () => { expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); }); - it("revalidates the server session state after a previous authenticated result", async () => { + it("memoizes the authenticated gate state after the first successful read", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -459,15 +487,9 @@ describe("resolveInitialServerAuthGateState", () => { status: "authenticated", }); await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie"], - sessionCookieName: "t3_session", - }, + status: "authenticated", }); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it("creates a pairing credential from the authenticated auth endpoint", async () => { diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts new file mode 100644 index 00000000000..096fc16ecc0 --- /dev/null +++ b/apps/web/src/branding.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalWindow = globalThis.window; + +afterEach(() => { + vi.resetModules(); + + if (originalWindow === undefined) { + Reflect.deleteProperty(globalThis, "window"); + return; + } + + globalThis.window = originalWindow; +}); + +describe("branding", () => { + it("uses injected desktop branding when available", async () => { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + desktopBridge: { + getAppBranding: () => ({ + baseName: "T3 Code", + stageLabel: "Nightly", + displayName: "T3 Code (Nightly)", + }), + }, + }, + }); + + const branding = await import("./branding"); + + expect(branding.APP_BASE_NAME).toBe("T3 Code"); + expect(branding.APP_STAGE_LABEL).toBe("Nightly"); + expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); + }); + + it("normalizes hosted app channel metadata", async () => { + vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly"); + + const branding = await import("./branding"); + + expect(branding.HOSTED_APP_CHANNEL).toBe("nightly"); + expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly"); + expect(branding.APP_STAGE_LABEL).toBe("Nightly"); + expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); + }); + + it("ignores unknown hosted app channels", async () => { + vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview"); + + const branding = await import("./branding"); + + expect(branding.HOSTED_APP_CHANNEL).toBeNull(); + expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull(); + }); +}); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index bffd9838158..5c1309ca06b 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,4 +1,25 @@ -export const APP_BASE_NAME = "T3 Code"; -export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; -export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; +import type { DesktopAppBranding } from "@t3tools/contracts"; + +function readInjectedDesktopAppBranding(): DesktopAppBranding | null { + if (typeof window === "undefined") { + return null; + } + + return window.desktopBridge?.getAppBranding?.() ?? null; +} + +const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); +const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); + +export const HOSTED_APP_CHANNEL = + hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; +export const HOSTED_APP_CHANNEL_LABEL = + HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; +export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; +export const APP_STAGE_LABEL = + injectedDesktopAppBranding?.stageLabel ?? + HOSTED_APP_CHANNEL_LABEL ?? + (import.meta.env.DEV ? "Dev" : "Alpha"); +export const APP_DISPLAY_NAME = + injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts deleted file mode 100644 index 5311fb40aed..00000000000 --- a/apps/web/src/chat-scroll.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; - -describe("isScrollContainerNearBottom", () => { - it("returns true when already at bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 600, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns true when within the auto-scroll threshold", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns false when the user is meaningfully above the bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 520, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(false); - }); - - it("clamps negative thresholds to zero", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 539, - clientHeight: 400, - scrollHeight: 1_000, - }, - -1, - ), - ).toBe(false); - }); - - it("falls back to the default threshold for non-finite values", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }, - Number.NaN, - ), - ).toBe(true); - expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); - }); -}); diff --git a/apps/web/src/chat-scroll.ts b/apps/web/src/chat-scroll.ts deleted file mode 100644 index 35190ab1b94..00000000000 --- a/apps/web/src/chat-scroll.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; - -interface ScrollPosition { - scrollTop: number; - clientHeight: number; - scrollHeight: number; -} - -export function isScrollContainerNearBottom( - position: ScrollPosition, - thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, -): boolean { - const threshold = Number.isFinite(thresholdPx) - ? Math.max(0, thresholdPx) - : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - - const { scrollTop, clientHeight, scrollHeight } = position; - if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { - return true; - } - - const distanceFromBottom = scrollHeight - clientHeight - scrollTop; - return distanceFromBottom <= threshold; -} diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index a74ce18ac30..e02168e09b3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -10,6 +10,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { wsBaseUrl: "wss://remote.example.com/", createdAt: "2026-04-09T00:00:00.000Z", lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, }; function createLocalStorageStub(): Storage { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 70f51d5c30a..30c949b37ac 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ wsBaseUrl: Schema.String, createdAt: Schema.String, lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey( + Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + }), + ), bearerToken: Schema.optionalKey(Schema.String), }); type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; @@ -37,7 +45,7 @@ function hasWindow(): boolean { function toPersistedSavedEnvironmentRecord( record: PersistedSavedEnvironmentRecord, ): PersistedSavedEnvironmentRecord { - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; } export function readBrowserClientSettings(): ClientSettings | null { @@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry( wsBaseUrl: record.wsBaseUrl, createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), bearerToken, } : toPersistedSavedEnvironmentRecord(record); @@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret( return record; } found = true; - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, bearerToken: secret, - } satisfies BrowserSavedEnvironmentRecord; + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; }), }); return found; diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts index 4f291d5a480..04b25529f2f 100644 --- a/apps/web/src/commandPaletteStore.ts +++ b/apps/web/src/commandPaletteStore.ts @@ -1,13 +1,32 @@ import { create } from "zustand"; +interface CommandPaletteOpenIntent { + kind: "add-project"; + requestId: number; +} + interface CommandPaletteStore { open: boolean; + openIntent: CommandPaletteOpenIntent | null; setOpen: (open: boolean) => void; toggleOpen: () => void; + openAddProject: () => void; + clearOpenIntent: () => void; } export const useCommandPaletteStore = create((set) => ({ open: false, - setOpen: (open) => set({ open }), - toggleOpen: () => set((state) => ({ open: !state.open })), + openIntent: null, + setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }), + toggleOpen: () => + set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })), + openAddProject: () => + set((state) => ({ + open: true, + openIntent: { + kind: "add-project", + requestId: (state.openIntent?.requestId ?? 0) + 1, + }, + })), + clearOpenIntent: () => set({ openIntent: null }), })); diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx new file mode 100644 index 00000000000..d0e21b3907e --- /dev/null +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; + +const HEIGHT_TRANSITION_FALLBACK_MS = 250; + +export function AnimatedHeight({ children }: { readonly children: ReactNode }) { + const contentRef = useRef(null); + const [heightState, setHeightState] = useState<{ + readonly height: number | null; + readonly isClipping: boolean; + }>({ height: null, isClipping: false }); + + useEffect(() => { + if (!heightState.isClipping) return; + const timeoutId = window.setTimeout(() => { + setHeightState((currentState) => + currentState.isClipping ? { ...currentState, isClipping: false } : currentState, + ); + }, HEIGHT_TRANSITION_FALLBACK_MS); + return () => window.clearTimeout(timeoutId); + }, [heightState.height, heightState.isClipping]); + + useLayoutEffect(() => { + const element = contentRef.current; + if (!element) return; + let firstFrameId: number | null = null; + let secondFrameId: number | null = null; + + const updateHeight = () => { + const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); + setHeightState((currentState) => { + if (currentState.height === nextHeight) return currentState; + return { + height: nextHeight, + isClipping: currentState.height !== null, + }; + }); + }; + const cancelPendingFrames = () => { + if (firstFrameId !== null) { + window.cancelAnimationFrame(firstFrameId); + firstFrameId = null; + } + if (secondFrameId !== null) { + window.cancelAnimationFrame(secondFrameId); + secondFrameId = null; + } + }; + const updateHeightAfterPaint = () => { + cancelPendingFrames(); + updateHeight(); + firstFrameId = window.requestAnimationFrame(() => { + firstFrameId = null; + updateHeight(); + secondFrameId = window.requestAnimationFrame(() => { + secondFrameId = null; + updateHeight(); + }); + }); + }; + + updateHeightAfterPaint(); + const resizeObserver = new ResizeObserver(updateHeightAfterPaint); + resizeObserver.observe(element); + return () => { + resizeObserver.disconnect(); + cancelPendingFrames(); + }; + }, []); + + return ( +
{ + if (event.target !== event.currentTarget || event.propertyName !== "height") return; + setHeightState((currentState) => + currentState.isClipping ? { ...currentState, isClipping: false } : currentState, + ); + }} + > +
{children}
+
+ ); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a2b27bb1e96..d98f30a1e5c 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -3,14 +3,39 @@ import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { + clearShortcutModifierState, + syncShortcutModifierStateFromKeyboardEvent, +} from "../shortcutModifierState"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; - export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowKeyUp = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowBlur = () => { + clearShortcutModifierState(); + }; + + window.addEventListener("keydown", onWindowKeyDown, true); + window.addEventListener("keyup", onWindowKeyUp, true); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown, true); + window.removeEventListener("keyup", onWindowKeyUp, true); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { @@ -18,8 +43,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { } const unsubscribe = onMenuAction((action) => { - if (action !== "open-settings") return; - void navigate({ to: "/settings" }); + if (action === "open-settings") { + void navigate({ to: "/settings" }); + } }); return () => { @@ -28,7 +54,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + { ).toBe("local"); }); - it("keeps new-worktree mode when selecting a base branch before worktree creation", () => { + it("keeps new-worktree mode when selecting a base ref before worktree creation", () => { expect( resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: null, @@ -37,7 +38,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { ).toBe("worktree"); }); - it("uses worktree mode when selecting a branch already attached to a worktree", () => { + it("uses worktree mode when selecting a ref already attached to a worktree", () => { expect( resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: "/repo/.t3/worktrees/feature-a", @@ -49,7 +50,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { }); describe("resolveBranchToolbarValue", () => { - it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => { + it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => { expect( resolveBranchToolbarValue({ envMode: "worktree", @@ -60,7 +61,7 @@ describe("resolveBranchToolbarValue", () => { ).toBe("main"); }); - it("keeps an explicitly selected worktree base branch", () => { + it("keeps an explicitly selected worktree base ref", () => { expect( resolveBranchToolbarValue({ envMode: "worktree", @@ -71,7 +72,7 @@ describe("resolveBranchToolbarValue", () => { ).toBe("feature/base"); }); - it("shows the actual checked-out branch when not selecting a new worktree base", () => { + it("shows the actual checked-out ref when not selecting a new worktree base", () => { expect( resolveBranchToolbarValue({ envMode: "local", @@ -157,6 +158,16 @@ describe("resolveCurrentWorkspaceLabel", () => { }); }); +describe("resolveLockedWorkspaceLabel", () => { + it("uses a shorter label for the main repo checkout", () => { + expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout"); + }); + + it("uses a shorter label for an attached worktree", () => { + expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree"); + }); +}); + describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); @@ -175,8 +186,8 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { }); describe("dedupeRemoteBranchesWithLocalMatches", () => { - it("hides remote refs when the matching local branch exists", () => { - const input: GitBranch[] = [ + it("hides remote refs when the matching local ref exists", () => { + const input: VcsRef[] = [ { name: "feature/demo", current: false, @@ -201,14 +212,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/demo", "origin/feature/remote-only", ]); }); it("keeps all entries when no local match exists for a remote ref", () => { - const input: GitBranch[] = [ + const input: VcsRef[] = [ { name: "feature/local", current: false, @@ -225,14 +236,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/local", "origin/feature/remote-only", ]); }); - it("keeps non-origin remote refs visible even when a matching local branch exists", () => { - const input: GitBranch[] = [ + it("keeps non-origin remote refs visible even when a matching local ref exists", () => { + const input: VcsRef[] = [ { name: "feature/demo", current: false, @@ -249,14 +260,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/demo", "my-org/upstream/feature/demo", ]); }); it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { - const input: GitBranch[] = [ + const input: VcsRef[] = [ { name: "upstream/feature", current: false, @@ -273,7 +284,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "upstream/feature", "my-org/upstream/feature", ]); @@ -281,12 +292,12 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }); describe("resolveBranchSelectionTarget", () => { - it("reuses an existing secondary worktree for the selected branch", () => { + it("reuses an existing secondary worktree for the selected ref", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: false, worktreePath: "/repo/.t3/worktrees/feature-b", }, @@ -298,12 +309,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("switches back to the main repo when the branch already lives there", () => { + it("switches back to the main repo when the ref already lives there", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: true, worktreePath: "/repo", }, @@ -315,12 +326,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("checks out the default branch in the main repo when leaving a secondary worktree", () => { + it("checks out the default ref in the main repo when leaving a secondary worktree", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: true, worktreePath: null, }, @@ -332,12 +343,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("keeps checkout in the current worktree for non-default branches", () => { + it("keeps checkout in the current worktree for non-default refs", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: false, worktreePath: null, }, @@ -362,7 +373,7 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("keeps the synthetic create-branch item visible for arbitrary branch input", () => { + it("keeps the synthetic create-ref item visible for arbitrary ref input", () => { expect( shouldIncludeBranchPickerItem({ itemValue: "__create_new_branch__:feature/demo", @@ -373,7 +384,7 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("still filters ordinary branch items by query text", () => { + it("still filters ordinary ref items by query text", () => { expect( shouldIncludeBranchPickerItem({ itemValue: "main", diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 54ec4370f4e..65388962c08 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,5 @@ -import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; -import { Schema } from "effect"; +import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, @@ -50,6 +50,10 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } +export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { + return activeWorktreePath ? "Worktree" : "Local checkout"; +} + export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; @@ -96,24 +100,24 @@ export function resolveBranchToolbarValue(input: { export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; - branch: Pick; + refName: Pick; }): { checkoutCwd: string; nextWorktreePath: string | null; reuseExistingWorktree: boolean; } { - const { activeProjectCwd, activeWorktreePath, branch } = input; + const { activeProjectCwd, activeWorktreePath, refName } = input; - if (branch.worktreePath) { + if (refName.worktreePath) { return { - checkoutCwd: branch.worktreePath, - nextWorktreePath: branch.worktreePath === activeProjectCwd ? null : branch.worktreePath, + checkoutCwd: refName.worktreePath, + nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath, reuseExistingWorktree: true, }; } const nextWorktreePath = - activeWorktreePath !== null && branch.isDefault ? null : activeWorktreePath; + activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath; return { checkoutCwd: nextWorktreePath ?? activeProjectCwd, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e91266d65fc..27c5c311c60 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,18 +1,41 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + ChevronDownIcon, + CloudIcon, + FolderGit2Icon, + FolderGitIcon, + FolderIcon, + MonitorIcon, +} from "lucide-react"; import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useIsMobile } from "../hooks/useMediaQuery"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { type EnvMode, type EnvironmentOption, + resolveCurrentWorkspaceLabel, + resolveEnvModeLabel, resolveEffectiveEnvMode, + resolveLockedWorkspaceLabel, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { Button } from "./ui/button"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; import { Separator } from "./ui/separator"; interface BranchToolbarProps { @@ -20,6 +43,9 @@ interface BranchToolbarProps { threadId: ThreadId; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; + effectiveEnvModeOverride?: EnvMode; + activeThreadBranchOverride?: string | null; + onActiveThreadBranchOverrideChange?: (branch: string | null) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -27,11 +53,150 @@ interface BranchToolbarProps { onEnvironmentChange?: (environmentId: EnvironmentId) => void; } +interface MobileRunContextSelectorProps { + envLocked: boolean; + envModeLocked: boolean; + environmentId: EnvironmentId; + availableEnvironments: readonly EnvironmentOption[] | undefined; + showEnvironmentPicker: boolean; + onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + onEnvModeChange: (mode: EnvMode) => void; +} + +const MobileRunContextSelector = memo(function MobileRunContextSelector({ + envLocked, + envModeLocked, + environmentId, + availableEnvironments, + showEnvironmentPicker, + onEnvironmentChange, + effectiveEnvMode, + activeWorktreePath, + onEnvModeChange, +}: MobileRunContextSelectorProps) { + const activeEnvironment = useMemo( + () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, + [availableEnvironments, environmentId], + ); + const WorkspaceIcon = + effectiveEnvMode === "worktree" + ? FolderGit2Icon + : activeWorktreePath + ? FolderGitIcon + : FolderIcon; + const workspaceLabel = envModeLocked + ? resolveLockedWorkspaceLabel(activeWorktreePath) + : effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath); + const isLocked = envLocked || envModeLocked; + const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; + const icon = showEnvironmentPicker ? ( + // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px + // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. + + + + + ) : ( + + ); + const triggerContent = ( + <> + {icon} + + {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + + + ); + + if (isLocked) { + return ( + + {triggerContent} + + ); + } + + return ( + + } + className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + > + {triggerContent} + + + + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( + <> + + Run on + onEnvironmentChange(value as EnvironmentId)} + > + {availableEnvironments.map((env) => { + const Icon = env.isPrimary ? MonitorIcon : CloudIcon; + return ( + + + + {env.label} + + + ); + })} + + + + + ) : null} + + Workspace + onEnvModeChange(value as EnvMode)} + > + + + {activeWorktreePath ? ( + + ) : ( + + )} + + {resolveCurrentWorkspaceLabel(activeWorktreePath)} + + + + + + + {resolveEnvModeLabel("worktree")} + + + + + + + ); +}); + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, draftId, onEnvModeChange, + effectiveEnvModeOverride, + activeThreadBranchOverride, + onActiveThreadBranchOverrideChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -59,45 +224,67 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeProject = useStore(activeProjectSelector); const hasActiveThread = serverThread !== undefined || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveEnvMode = resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread: serverThread !== undefined, - draftThreadEnvMode: draftThread?.envMode, - }); + const effectiveEnvMode = + effectiveEnvModeOverride ?? + resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread: serverThread !== undefined, + draftThreadEnvMode: draftThread?.envMode, + }); const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); - const showEnvironmentPicker = - availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; + const showEnvironmentPicker = Boolean( + availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, + ); + const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; return ( -
-
- {showEnvironmentPicker && ( - <> - - - - )} - + {isMobile ? ( + -
+ ) : ( +
+ {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + <> + + + + )} + +
+ )} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 76f64d93fe1..2e30edfc02c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,10 +1,9 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon } from "lucide-react"; import { - type CSSProperties, useCallback, useDeferredValue, useEffect, @@ -20,7 +19,9 @@ import { readEnvironmentApi } from "../environmentApi"; import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; import { useGitStatus } from "../lib/gitStatusState"; import { newCommandId } from "../lib/utils"; +import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; +import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { @@ -38,17 +39,22 @@ import { ComboboxInput, ComboboxItem, ComboboxList, + ComboboxListVirtualized, ComboboxPopup, ComboboxStatus, ComboboxTrigger, } from "./ui/combobox"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; interface BranchToolbarBranchSelectorProps { + className?: string; environmentId: EnvironmentId; threadId: ThreadId; draftId?: DraftId; envLocked: boolean; + effectiveEnvModeOverride?: "local" | "worktree"; + activeThreadBranchOverride?: string | null; + onActiveThreadBranchOverrideChange?: (refName: string | null) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -64,7 +70,7 @@ function getBranchTriggerLabel(input: { }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; if (!resolvedActiveBranch) { - return "Select branch"; + return "Select ref"; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { return `From ${resolvedActiveBranch}`; @@ -73,10 +79,14 @@ function getBranchTriggerLabel(input: { } export function BranchToolbarBranchSelector({ + className, environmentId, threadId, draftId, envLocked, + effectiveEnvModeOverride, + activeThreadBranchOverride, + onActiveThreadBranchOverrideChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { @@ -108,16 +118,21 @@ export function BranchToolbarBranchSelector({ const activeProject = useStore(activeProjectSelector); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); - const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; + const activeThreadBranch = + activeThreadBranchOverride !== undefined + ? activeThreadBranchOverride + : (serverThread?.branch ?? draftThread?.branch ?? null); const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const activeProjectCwd = activeProject?.cwd ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== undefined; - const effectiveEnvMode = resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread, - draftThreadEnvMode: draftThread?.envMode, - }); + const effectiveEnvMode = + effectiveEnvModeOverride ?? + resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread, + draftThreadEnvMode: draftThread?.envMode, + }); // --------------------------------------------------------------------------- // Thread branch mutation (colocated — only this component calls it) @@ -146,6 +161,7 @@ export function BranchToolbarBranchSelector({ }); } if (hasServerThread) { + onActiveThreadBranchOverrideChange?.(branch); setThreadBranchAction(threadRef, branch, worktreePath); return; } @@ -167,6 +183,7 @@ export function BranchToolbarBranchSelector({ serverSession, activeWorktreePath, hasServerThread, + onActiveThreadBranchOverrideChange, setThreadBranchAction, setDraftThreadContext, draftId, @@ -177,7 +194,7 @@ export function BranchToolbarBranchSelector({ ); // --------------------------------------------------------------------------- - // Git branch queries + // Git ref queries // --------------------------------------------------------------------------- const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); @@ -208,22 +225,27 @@ export function BranchToolbarBranchSelector({ query: deferredTrimmedBranchQuery, }), ); - const branches = useMemo( - () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], + const refs = useMemo( + () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [], [branchesSearchData?.pages], ); const currentGitBranch = - branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; + branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider), + [branchStatusQuery.data?.sourceControlProvider], + ); + const SourceControlIcon = sourceControlPresentation.Icon; const canonicalActiveBranch = resolveBranchToolbarValue({ envMode: effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch, }); - const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); + const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( - () => new Map(branches.map((branch) => [branch.name, branch] as const)), - [branches], + () => new Map(refs.map((refName) => [refName.name, refName] as const)), + [refs], ); const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); @@ -273,11 +295,11 @@ export function BranchToolbarBranchSelector({ const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending - ? "Loading branches..." + ? "Loading refs..." : isFetchingNextPage - ? "Loading more branches..." + ? "Loading more refs..." : hasNextPage - ? `Showing ${branches.length} of ${totalBranchCount} branches` + ? `Showing ${refs.length} of ${totalBranchCount} refs` : null; // --------------------------------------------------------------------------- @@ -287,17 +309,17 @@ export function BranchToolbarBranchSelector({ startBranchActionTransition(async () => { await action().catch(() => undefined); await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) + .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) .catch(() => undefined); }); }; - const selectBranch = (branch: GitBranch) => { + const selectBranch = (refName: VcsRef) => { const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { - setThreadBranch(branch.name, null); + setThreadBranch(refName.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -306,19 +328,19 @@ export function BranchToolbarBranchSelector({ const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeWorktreePath, - branch, + refName, }); if (selectionTarget.reuseExistingWorktree) { - setThreadBranch(branch.name, selectionTarget.nextWorktreePath); + setThreadBranch(refName.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } - const selectedBranchName = branch.isRemote - ? deriveLocalBranchNameFromRemoteRef(branch.name) - : branch.name; + const selectedBranchName = refName.isRemote + ? deriveLocalBranchNameFromRemoteRef(refName.name) + : refName.name; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); @@ -327,27 +349,29 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - const checkoutResult = await api.git.checkout({ + const checkoutResult = await api.vcs.switchRef({ cwd: selectionTarget.checkoutCwd, - branch: branch.name, + refName: refName.name, }); - const nextBranchName = branch.isRemote - ? (checkoutResult.branch ?? selectedBranchName) + const nextBranchName = refName.isRemote + ? (checkoutResult.refName ?? selectedBranchName) : selectedBranchName; setOptimisticBranch(nextBranchName); setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch ref.", + description: toBranchActionErrorMessage(error), + }), + ); } }); }; - const createBranch = (rawName: string) => { + const createRef = (rawName: string) => { const name = rawName.trim(); const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !name || isBranchActionPending) return; @@ -359,20 +383,22 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); try { - const createBranchResult = await api.git.createBranch({ + const createBranchResult = await api.vcs.createRef({ cwd: branchCwd, - branch: name, - checkout: true, + refName: name, + switchRef: true, }); - setOptimisticBranch(createBranchResult.branch); - setThreadBranch(createBranchResult.branch, activeWorktreePath); + setOptimisticBranch(createBranchResult.refName); + setThreadBranch(createBranchResult.refName, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); - toastManager.add({ - type: "error", - title: "Failed to create and checkout branch.", - description: toBranchActionErrorMessage(error), - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to create and switch ref.", + description: toBranchActionErrorMessage(error), + }), + ); } }); }; @@ -390,7 +416,7 @@ export function BranchToolbarBranchSelector({ }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); // --------------------------------------------------------------------------- - // Combobox / virtualizer plumbing + // Combobox / list plumbing // --------------------------------------------------------------------------- const handleOpenChange = useCallback( (open: boolean) => { @@ -400,7 +426,7 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(environmentId, branchCwd), + queryKey: gitQueryKeys.refs(environmentId, branchCwd), }); }, [branchCwd, environmentId, queryClient], @@ -425,49 +451,22 @@ export function BranchToolbarBranchSelector({ void fetchNextPage().catch(() => undefined); }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); - const branchListVirtualizer = useVirtualizer({ - count: filteredBranchPickerItems.length, - estimateSize: (index) => - filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28, - getScrollElement: () => branchListScrollElementRef.current, - overscan: 12, - enabled: isBranchMenuOpen && shouldVirtualizeBranchList, - initialRect: { - height: 224, - width: 0, - }, - }); - const virtualBranchRows = branchListVirtualizer.getVirtualItems(); - const setBranchListRef = useCallback( - (element: HTMLDivElement | null) => { - branchListScrollElementRef.current = - (element?.parentElement as HTMLDivElement | null) ?? null; - if (element) { - branchListVirtualizer.measure(); - } - }, - [branchListVirtualizer], - ); - - useEffect(() => { - if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; - queueMicrotask(() => { - branchListVirtualizer.measure(); - }); - }, [ - branchListVirtualizer, - filteredBranchPickerItems.length, - isBranchMenuOpen, - shouldVirtualizeBranchList, - ]); + const branchListRef = useRef(null); + const setBranchListRef = useCallback((element: HTMLDivElement | null) => { + branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; + }, []); useEffect(() => { if (!isBranchMenuOpen) { return; } - branchListScrollElementRef.current?.scrollTo({ top: 0 }); - }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); + if (shouldVirtualizeBranchList) { + branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + } else { + branchListScrollElementRef.current?.scrollTo({ top: 0 }); + } + }, [deferredTrimmedBranchQuery, isBranchMenuOpen, shouldVirtualizeBranchList]); useEffect(() => { const scrollElement = branchListScrollElementRef.current; @@ -487,8 +486,9 @@ export function BranchToolbarBranchSelector({ }, [isBranchMenuOpen, maybeFetchNextBranchPage]); useEffect(() => { + if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [branches.length, maybeFetchNextBranchPage]); + }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -496,7 +496,7 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, }); - function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { + function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { return ( { if (!prReference || !onCheckoutPullRequestRequest) { return; @@ -515,9 +514,14 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest(prReference); }} > -
- Checkout Pull Request - {prReference} +
+ + + + Checkout {sourceControlPresentation.terminology.singular} + + {prReference} +
); @@ -529,26 +533,25 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} - onClick={() => createBranch(trimmedBranchQuery)} + onClick={() => createRef(trimmedBranchQuery)} > - Create new branch "{trimmedBranchQuery}" + Create new ref "{trimmedBranchQuery}" ); } - const branch = branchByName.get(itemValue); - if (!branch) return null; + const refName = branchByName.get(itemValue); + if (!refName) return null; const hasSecondaryWorktree = - branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; - const badge = branch.current + refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; + const badge = refName.current ? "current" : hasSecondaryWorktree ? "worktree" - : branch.isRemote + : refName.isRemote ? "remote" - : branch.isDefault + : refName.isDefault ? "default" : null; return ( @@ -557,8 +560,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - style={style} - onClick={() => selectBranch(branch)} + onClick={() => selectBranch(refName)} >
{itemValue} @@ -575,8 +577,13 @@ export function BranchToolbarBranchSelector({ autoHighlight virtualized={shouldVirtualizeBranchList} onItemHighlighted={(_value, eventDetails) => { - if (!isBranchMenuOpen || eventDetails.index < 0) return; - branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); + if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { + return; + } + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); }} onOpenChange={handleOpenChange} open={isBranchMenuOpen} @@ -584,50 +591,50 @@ export function BranchToolbarBranchSelector({ > } - className="text-muted-foreground/70 hover:text-foreground/80" - disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} + className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} + disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} > - {triggerLabel} - + {triggerLabel} +
setBranchQuery(event.target.value)} />
- No branches found. - - - {shouldVirtualizeBranchList ? ( -
No refs found. + + {shouldVirtualizeBranchList ? ( + + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage().catch(() => undefined); + } }} - > - {virtualBranchRows.map((virtualRow) => { - const itemValue = filteredBranchPickerItems[virtualRow.index]; - if (!itemValue) return null; - return renderPickerItem(itemValue, virtualRow.index, { - position: "absolute", - top: 0, - left: 0, - width: "100%", - transform: `translateY(${virtualRow.start}px)`, - }); - })} -
- ) : ( - filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) - )} -
+ style={{ maxHeight: "14rem" }} + /> + + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} + + )} {branchStatusText ? {branchStatusText} : null}
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 39bf50359dc..6d06882662f 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -4,6 +4,7 @@ import { memo, useMemo } from "react"; import { resolveCurrentWorkspaceLabel, resolveEnvModeLabel, + resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; import { @@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {activeWorktreePath ? ( <> - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath)} ) : ( <> - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath)} )} @@ -57,6 +58,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe return ( onEnvironmentChange(value as EnvironmentId)} items={environmentItems} diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 48f345bd2f9..4e8698d4b1e 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -63,9 +63,9 @@ describe("ChatMarkdown", () => { ); try { - const link = page.getByRole("link", { name: "PermissionRule.ts:1" }); + const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); + await expect.element(link).toHaveAttribute("href", `${filePath}:1`); await link.click(); @@ -76,4 +76,66 @@ describe("ChatMarkdown", () => { await screen.unmount(); } }); + + it("shows column information inline when present", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}:1:7`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith( + expect.anything(), + `${filePath}:1:7`, + ); + }); + } finally { + await screen.unmount(); + } + }); + + it("disambiguates duplicate file basenames inline", async () => { + const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx"; + const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx"; + const screen = await render( + , + ); + + try { + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) + .toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); + + it("keeps normal web links unchanged", async () => { + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); + await expect.element(link).toHaveAttribute("target", "_blank"); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 366f9231579..6a85ccee96a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,8 +1,10 @@ import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; +import type { ServerProviderSkill } from "@t3tools/contracts"; import React, { Children, Suspense, + type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, @@ -17,13 +19,22 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; +import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; +import { + normalizeMarkdownLinkDestination, + resolveMarkdownFileLinkMeta, + rewriteMarkdownFileUriHref, +} from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { cn } from "../lib/utils"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -50,8 +61,11 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; isStreaming?: boolean; + skills?: ReadonlyArray>; } +const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; + const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; @@ -206,6 +220,32 @@ function SuspenseShikiCodeBlock({ ); } + return ( + + ); +} + +interface UncachedShikiCodeBlockProps { + code: string; + language: string; + themeName: DiffThemeName; + cacheKey: string; + isStreaming: boolean; +} + +function UncachedShikiCodeBlock({ + code, + language, + themeName, + cacheKey, + isStreaming, +}: UncachedShikiCodeBlockProps) { const highlighter = use(getHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { @@ -236,34 +276,307 @@ function SuspenseShikiCodeBlock({ ); } -function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { +interface MarkdownFileLinkProps { + href: string; + targetPath: string; + displayPath: string; + filePath: string; + label: string; + theme: "light" | "dark"; + className?: string | undefined; +} + +const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; +const MARKDOWN_FILE_LINK_CLASS_NAME = + "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; +const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; +const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; + +function pathParentSegments(path: string): string[] { + const normalized = path.replaceAll("\\", "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + return segments.slice(0, -1); +} + +function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { + const groups = new Map>(); + for (const filePath of filePaths) { + const pathSegments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); + const basename = pathSegments[pathSegments.length - 1]; + if (!basename) continue; + const group = groups.get(basename) ?? new Set(); + group.add(filePath); + groups.set(basename, group); + } + + const suffixByPath = new Map(); + for (const group of groups.values()) { + const uniquePaths = [...group]; + if (uniquePaths.length < 2) continue; + + const parentSegmentsByPath = new Map( + uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), + ); + const minUniqueDepthByPath = new Map(); + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + let resolvedDepth = segments.length; + for (let depth = 1; depth <= segments.length; depth += 1) { + const candidate = segments.slice(-depth).join("/"); + const collision = uniquePaths.some((otherPath) => { + if (otherPath === filePath) return false; + const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; + return otherSegments.slice(-depth).join("/") === candidate; + }); + if (!collision) { + resolvedDepth = depth; + break; + } + } + minUniqueDepthByPath.set(filePath, resolvedDepth); + } + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + if (segments.length === 0) continue; + const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; + const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); + } + } + + return suffixByPath; +} + +function extractMarkdownLinkHrefs(text: string): string[] { + const hrefs: string[] = []; + for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { + const href = match[1]?.trim(); + if (!href) continue; + hrefs.push(href); + } + return hrefs; +} + +function normalizeMarkdownLinkHrefKey(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; +} + +const MarkdownFileLink = memo(function MarkdownFileLink({ + href, + targetPath, + displayPath, + filePath, + label, + theme, + className, +}: MarkdownFileLinkProps) { + const handleOpen = useCallback(() => { + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Open in editor is unavailable", + }); + return; + } + + void openInPreferredEditor(api, targetPath).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, [targetPath]); + + const handleCopy = useCallback((value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: "Clipboard API unavailable.", + }), + ); + return; + } + + void navigator.clipboard.writeText(value).then( + () => { + toastManager.add({ + type: "success", + title: `${title} copied`, + description: value, + }); + }, + (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, []); + + const handleContextMenu = useCallback( + async (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open in editor" }, + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + + if (clicked === "open") { + handleOpen(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + }, + [displayPath, handleCopy, handleOpen, targetPath], + ); + + return ( + + { + event.preventDefault(); + event.stopPropagation(); + handleOpen(); + }} + onContextMenu={handleContextMenu} + > + + {label} +
+ } + /> + +
+ {displayPath} +
+
+ + ); +}, areMarkdownFileLinkPropsEqual); + +function areMarkdownFileLinkPropsEqual( + previous: Readonly, + next: Readonly, +): boolean { + return ( + previous.href === next.href && + previous.targetPath === next.targetPath && + previous.displayPath === next.displayPath && + previous.filePath === next.filePath && + previous.label === next.label && + previous.theme === next.theme && + previous.className === next.className + ); +} + +function ChatMarkdown({ + text, + cwd, + isStreaming = false, + skills = EMPTY_MARKDOWN_SKILLS, +}: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const markdownFileLinkMetaByHref = useMemo(() => { + const metaByHref = new Map< + string, + NonNullable> + >(); + for (const href of extractMarkdownLinkHrefs(text)) { + const normalizedHref = normalizeMarkdownLinkHrefKey(href); + if (metaByHref.has(normalizedHref)) continue; + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + if (meta) { + metaByHref.set(normalizedHref, meta); + } + } + return metaByHref; + }, [cwd, text]); + const fileLinkParentSuffixByPath = useMemo(() => { + const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + return buildFileLinkParentSuffixByPath(filePaths); + }, [markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); const markdownComponents = useMemo( () => ({ + p({ node: _node, children, ...props }) { + return

{renderSkillInlineMarkdownChildren(children, skills)}

; + }, + li({ node: _node, children, ...props }) { + return
  • {renderSkillInlineMarkdownChildren(children, skills)}
  • ; + }, a({ node: _node, href, ...props }) { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); - if (!targetPath) { + const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + if (!fileLinkMeta) { return ; } + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + return ( - { - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (api) { - void openInPreferredEditor(api, targetPath); - } else { - console.warn("Native API not found. Unable to open file in editor."); - } - }} + ); }, @@ -289,7 +602,14 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); }, }), - [cwd, diffThemeName, isStreaming], + [ + diffThemeName, + fileLinkParentSuffixByPath, + isStreaming, + markdownFileLinkMetaByHref, + resolvedTheme, + skills, + ], ); return ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a65f5755f9a..bb62d8edbc0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -5,10 +5,12 @@ import { EventId, ORCHESTRATION_WS_METHODS, EnvironmentId, + type EnvironmentApi, type MessageId, - type OrchestrationEvent, type OrchestrationReadModel, type ProjectId, + ProviderDriverKind, + ProviderInstanceId, type ServerConfig, type ServerLifecycleWelcomePayload, type ThreadId, @@ -16,14 +18,13 @@ import { WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, + ServerConfig as ServerConfigSchema, } from "@t3tools/contracts"; -import { - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; import { page } from "vitest/browser"; @@ -32,6 +33,16 @@ import { render } from "vitest-browser-react"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { + __resetEnvironmentApiOverridesForTests, + __setEnvironmentApiOverrideForTests, +} from "../environmentApi"; +import { + resetSavedEnvironmentRegistryStoreForTests, + resetSavedEnvironmentRuntimeStoreForTests, + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, removeInlineTerminalContextPlaceholder, @@ -42,12 +53,13 @@ import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; + import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; vi.mock("../lib/gitStatusState", () => ({ @@ -58,18 +70,32 @@ vi.mock("../lib/gitStatusState", () => ({ })); const THREAD_ID = "thread-browser-test" as ThreadId; +const THREAD_TITLE = "Browser test thread"; const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const SECOND_PROJECT_ID = "project-2" as ProjectId; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); +const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( + { + environmentId: LOCAL_ENVIRONMENT_ID, + id: PROJECT_ID, + cwd: "/repo/project", + repositoryIdentity: null, + }, + { + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, + }, +); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; +const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; interface TestFixture { snapshot: OrchestrationReadModel; @@ -82,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness(); const wsRequests = rpcHarness.requests; let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; const wsLink = ws.link(/ws(s)?:\/\/.*/); +const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); interface ViewportSpec { name: string; @@ -112,28 +139,10 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { textTolerancePx: 56, attachmentTolerancePx: 56, }; -const TEXT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, -] as const satisfies readonly ViewportSpec[]; -const ATTACHMENT_VIEWPORT_MATRIX = [ - { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, -] as const satisfies readonly ViewportSpec[]; - -interface UserRowMeasurement { - measuredRowHeightPx: number; - timelineWidthMeasuredPx: number; - renderedInVirtualizedRegion: boolean; -} interface MountedChatView { [Symbol.asyncDispose]: () => Promise; cleanup: () => Promise; - measureUserRow: (targetMessageId: MessageId) => Promise; setViewport: (viewport: ViewportSpec) => Promise; setContainerSize: (viewport: Pick) => Promise; router: ReturnType; @@ -164,7 +173,8 @@ function createBaseServerConfig(): ServerConfig { issues: [], providers: [ { - provider: "codex", + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), enabled: true, installed: true, version: "0.116.0", @@ -190,6 +200,37 @@ function createBaseServerConfig(): ServerConfig { }; } +function createMockEnvironmentApi(input: { + browse: EnvironmentApi["filesystem"]["browse"]; + dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; +}): EnvironmentApi { + return { + terminal: {} as EnvironmentApi["terminal"], + projects: {} as EnvironmentApi["projects"], + filesystem: { + browse: input.browse, + }, + sourceControl: {} as EnvironmentApi["sourceControl"], + vcs: {} as EnvironmentApi["vcs"], + git: {} as EnvironmentApi["git"], + orchestration: { + dispatchCommand: input.dispatchCommand, + getTurnDiff: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getTurnDiff"], + getFullThreadDiff: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], + getArchivedShellSnapshot: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], + subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], + subscribeThread: (() => () => + undefined) as EnvironmentApi["orchestration"]["subscribeThread"], + }, + }; +} + function createUserMessage(options: { id: MessageId; text: string; @@ -294,7 +335,7 @@ function createSnapshotForTargetUser(options: { title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, scripts: [], @@ -307,9 +348,9 @@ function createSnapshotForTargetUser(options: { { id: THREAD_ID, projectId: PROJECT_ID, - title: "Browser test thread", + title: THREAD_TITLE, modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", @@ -374,7 +415,7 @@ function addThreadToSnapshot( projectId: PROJECT_ID, title: "New thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", @@ -404,74 +445,94 @@ function addThreadToSnapshot( }; } -function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { +function toShellThread(thread: OrchestrationReadModel["threads"][number]) { return { - sequence, - eventId: EventId.make(`event-thread-created-${sequence}`), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: NOW_ISO, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.created", - payload: { - threadId, - projectId: PROJECT_ID, - title: "New thread", - modelSelection: { - provider: "codex", - model: "gpt-5", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - }, + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + session: thread.session, + latestUserMessageAt: + thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, }; } -function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { +function toShellSnapshot(snapshot: OrchestrationReadModel) { return { - sequence, - eventId: EventId.make(`event-thread-session-set-${sequence}`), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: NOW_ISO, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.session-set", - payload: { - threadId, - session: { - threadId, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: `turn-${threadId}` as TurnId, - lastError: null, - updatedAt: NOW_ISO, - }, - }, + snapshotSequence: snapshot.snapshotSequence, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection, + scripts: project.scripts, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })), + threads: snapshot.threads.map(toShellThread), + updatedAt: snapshot.updatedAt, + }; +} + +function updateThreadSessionInSnapshot( + snapshot: OrchestrationReadModel, + threadId: ThreadId, + session: OrchestrationReadModel["threads"][number]["session"], +): OrchestrationReadModel { + return { + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((thread) => + thread.id === threadId + ? { + ...thread, + session, + updatedAt: NOW_ISO, + } + : thread, + ), }; } -function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { - rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); +function sendShellThreadUpsert( + threadId: ThreadId, + options?: { + readonly session?: OrchestrationReadModel["threads"][number]["session"]; + }, +): void { + const thread = fixture.snapshot.threads.find((entry) => entry.id === threadId); + if (!thread) { + throw new Error(`Expected thread ${threadId} in snapshot.`); + } + + const shellThread = + options?.session !== undefined + ? toShellThread({ ...thread, session: options.session }) + : toShellThread(thread); + rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeShell, { + kind: "thread-upserted", + sequence: fixture.snapshot.snapshotSequence, + thread: shellThread, + }); } async function waitForWsClient(): Promise { await vi.waitFor( () => { expect( - wsRequests.some( - (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, - ), + wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), ).toBe(true); expect( wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), @@ -531,15 +592,21 @@ async function waitForAppBootstrap(): Promise { async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise { await waitForWsClient(); fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); - sendOrchestrationDomainEvent( - createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), - ); + fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null); + sendShellThreadUpsert(threadId, { session: null }); } async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { - sendOrchestrationDomainEvent( - createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), - ); + fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: `turn-${threadId}` as TurnId, + lastError: null, + updatedAt: NOW_ISO, + }); + sendShellThreadUpsert(threadId); } async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { @@ -685,7 +752,7 @@ function createSnapshotWithSecondaryProject(options?: { id: "thread-secondary-project" as ThreadId, projectId: SECOND_PROJECT_ID, title: "Release checklist", - modelSelection: { provider: "codex", model: "gpt-5" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, interactionMode: "default", runtimeMode: "full-access", branch: "release/docs-portal", @@ -717,7 +784,7 @@ function createSnapshotWithSecondaryProject(options?: { id: ARCHIVED_SECONDARY_THREAD_ID, projectId: SECOND_PROJECT_ID, title: "Archived Docs Notes", - modelSelection: { provider: "codex", model: "gpt-5" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, interactionMode: "default", runtimeMode: "full-access", branch: "release/docs-archive", @@ -752,7 +819,7 @@ function createSnapshotWithSecondaryProject(options?: { id: SECOND_PROJECT_ID, title: "Docs Portal", workspaceRoot: "/repo/clients/docs-portal", - defaultModelSelection: { provider: "codex", model: "gpt-5" }, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -829,7 +896,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { } function createSnapshotWithPlanFollowUpPrompt(options?: { - modelSelection?: { provider: "codex"; model: string }; + modelSelection?: { instanceId: ProviderInstanceId; model: string }; planMarkdown?: string; }): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ @@ -837,7 +904,7 @@ function createSnapshotWithPlanFollowUpPrompt(options?: { targetText: "plan follow-up thread", }); const modelSelection = options?.modelSelection ?? { - provider: "codex" as const, + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }; const planMarkdown = @@ -890,19 +957,83 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { return customResult; } const tag = body._tag; - if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { - return fixture.snapshot; - } if (tag === WS_METHODS.serverGetConfig) { - return fixture.serverConfig; + return encodeServerConfig(fixture.serverConfig); + } + if (tag === WS_METHODS.serverDiscoverSourceControl) { + return { + versionControlSystems: [], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("gh version 2.0.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("github.com"), + detail: Option.none(), + }, + }, + { + kind: "gitlab", + label: "GitLab", + executable: "glab", + status: "available", + version: Option.some("glab version 1.0.0"), + installHint: "Install GitLab CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("gitlab.com"), + detail: Option.none(), + }, + }, + { + kind: "bitbucket", + label: "Bitbucket", + executable: "Bitbucket REST API", + status: "available", + version: Option.none(), + installHint: "Set Bitbucket API token environment variables.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }, + }, + { + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + status: "available", + version: Option.some("azure-cli 2.0.0"), + installHint: "Install Azure CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("t3-oss"), + host: Option.some("dev.azure.com"), + detail: Option.none(), + }, + }, + ], + }; } - if (tag === WS_METHODS.gitListBranches) { + if (tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, - branches: [ + refs: [ { name: "main", current: true, @@ -1333,6 +1464,18 @@ function dispatchChatNewShortcut(): void { ); } +function releaseModShortcut(key?: string): void { + window.dispatchEvent( + new KeyboardEvent("keyup", { + key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), + metaKey: false, + ctrlKey: false, + bubbles: true, + cancelable: true, + }), + ); +} + async function triggerChatNewShortcutUntilPath( router: ReturnType, predicate: (pathname: string) => boolean, @@ -1378,89 +1521,42 @@ async function waitForCommandPaletteShortcutLabel(): Promise { ); } -async function waitForImagesToLoad(scope: ParentNode): Promise { - const images = Array.from(scope.querySelectorAll("img")); - if (images.length === 0) { - return; - } - await Promise.all( - images.map( - (image) => - new Promise((resolve) => { - if (image.complete) { - resolve(); - return; - } - image.addEventListener("load", () => resolve(), { once: true }); - image.addEventListener("error", () => resolve(), { once: true }); - }), - ), +async function waitForCommandPaletteInput(placeholder: string): Promise { + return waitForElement( + () => document.querySelector(`input[placeholder="${placeholder}"]`) as HTMLInputElement | null, + `Command palette input with placeholder "${placeholder}" did not render.`, ); - await waitForLayout(); } -async function measureUserRow(options: { - host: HTMLElement; - targetMessageId: MessageId; -}): Promise { - const { host, targetMessageId } = options; - const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; - - const scrollContainer = await waitForElement( - () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), - "Unable to find ChatView message scroll container.", - ); - - let row: HTMLElement | null = null; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - row = host.querySelector(rowSelector); - expect(row, "Unable to locate targeted user message row.").toBeTruthy(); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - - await waitForImagesToLoad(row!); - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - - const timelineRoot = - row!.closest('[data-timeline-root="true"]') ?? - host.querySelector('[data-timeline-root="true"]'); - if (!(timelineRoot instanceof HTMLElement)) { - throw new Error("Unable to locate timeline root container."); +function getCommandPaletteLegendEntries(): string[] { + const footer = document.querySelector('[data-slot="command-footer"]'); + if (!footer) { + return []; } - let timelineWidthMeasuredPx = 0; - let measuredRowHeightPx = 0; - let renderedInVirtualizedRegion = false; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - const measuredRow = host.querySelector(rowSelector); - expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); - timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; - measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; - renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; - expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); - expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); - }, - { - timeout: 4_000, - interval: 16, - }, - ); + return Array.from(footer.querySelectorAll('[data-slot="kbd-group"]')) + .map((group) => + Array.from(group.children) + .map((child) => child.textContent?.trim() ?? "") + .filter((value) => value.length > 0) + .join(" "), + ) + .filter((value) => value.length > 0); +} - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; +async function dispatchInputKey( + input: HTMLInputElement, + init: Pick, +): Promise { + input.focus(); + input.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }), + ); + await waitForLayout(); } async function mountChatView(options: { @@ -1515,7 +1611,6 @@ async function mountChatView(options: { return { [Symbol.asyncDispose]: cleanup, cleanup, - measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), setViewport: async (viewport: ViewportSpec) => { await setViewport(viewport); await waitForProductionStyles(); @@ -1529,23 +1624,6 @@ async function mountChatView(options: { }; } -async function measureUserRowAtViewport(options: { - snapshot: OrchestrationReadModel; - targetMessageId: MessageId; - viewport: ViewportSpec; -}): Promise { - const mounted = await mountChatView({ - viewport: options.viewport, - snapshot: options.snapshot, - }); - - try { - return await mounted.measureUserRow(options.targetMessageId); - } finally { - await mounted.cleanup(); - } -} - describe("ChatView timeline estimator parity (full app)", () => { beforeAll(async () => { fixture = buildFixture( @@ -1587,10 +1665,32 @@ describe("ChatView timeline estimator parity (full app)", () => { { version: 1, type: "snapshot", - config: fixture.serverConfig, + config: encodeServerConfig(fixture.serverConfig), + }, + ]; + } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { + return [ + { + kind: "snapshot", + snapshot: toShellSnapshot(fixture.snapshot), }, ]; } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) { + const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId); + return thread + ? [ + { + kind: "snapshot", + snapshot: { + snapshotSequence: fixture.snapshot.snapshotSequence, + thread, + }, + }, + ] + : []; + } return []; }, }); @@ -1600,6 +1700,10 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; + __resetEnvironmentApiOverridesForTests(); + resetSavedEnvironmentRegistryStoreForTests(); + resetSavedEnvironmentRuntimeStoreForTests(); + Reflect.deleteProperty(window, "desktopBridge"); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, @@ -1609,6 +1713,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); useCommandPaletteStore.setState({ open: false, + openIntent: null, }); useStore.setState({ activeEnvironmentId: null, @@ -1633,45 +1738,79 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; }); - it.each(TEXT_VIEWPORT_MATRIX)( - "keeps long user message estimate close at the $name viewport", - async (viewport) => { - const userText = "x".repeat(3_200); - const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId; - const mounted = await mountChatView({ - viewport, - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }), - }); + it("renders locked single-environment mobile run context as a static workspace label", async () => { + const mounted = await mountChatView({ + viewport: COMPACT_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-mobile-locked-workspace" as MessageId, + targetText: "locked mobile workspace", + }), + }); + + try { + await waitForElement( + () => + Array.from(document.querySelectorAll("span")).find( + (element) => element.textContent?.trim() === "Local checkout", + ) ?? null, + "Unable to find static mobile workspace label.", + ); - try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); + expect(findButtonByText("Local checkout")).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); - expect(renderedInVirtualizedRegion).toBe(true); + it("keeps dismiss-only composer banners aligned on mobile", async () => { + const mounted = await mountChatView({ + viewport: COMPACT_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-mobile-version-banner" as MessageId, + targetText: "mobile version banner", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + environment: { + ...nextFixture.serverConfig.environment, + serverVersion: "9.9.9", + }, + }; + }, + }); - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); + try { + const banner = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="alert"]')).find( + (element) => element.textContent?.includes("Client and server versions differ"), + ) ?? null, + "Unable to find version mismatch banner.", + ); + const title = banner.querySelector('[data-slot="alert-title"]'); + const description = banner.querySelector('[data-slot="alert-description"]'); + const dismissButton = banner.querySelector( + 'button[aria-label="Dismiss version mismatch warning"]', + ); - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, - ); - } finally { - await mounted.cleanup(); - } - }, - ); + expect(title).toBeTruthy(); + expect(description).toBeTruthy(); + expect(dismissButton).toBeTruthy(); + expect(dismissButton!.getBoundingClientRect().top).toBeLessThan( + description!.getBoundingClientRect().top, + ); + } finally { + await mounted.cleanup(); + } + }); - it("re-expands the bootstrap project using its scoped key", async () => { + it("re-expands the bootstrap project using its logical key", async () => { useUiStateStore.setState({ projectExpandedById: { - [PROJECT_KEY]: false, + [PROJECT_LOGICAL_KEY]: false, }, - projectOrder: [PROJECT_KEY], + projectOrder: [PROJECT_LOGICAL_KEY], threadLastVisitedAtById: {}, }); @@ -1686,7 +1825,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -1695,130 +1834,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { - const userText = "x".repeat(3_200); - const targetMessageId = "msg-user-target-resize" as MessageId; - const mounted = await mountChatView({ - viewport: TEXT_VIEWPORT_MATRIX[0], - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }), - }); - - try { - const measurements: Array< - UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } - > = []; - - for (const viewport of TEXT_VIEWPORT_MATRIX) { - await mounted.setViewport(viewport); - const measurement = await mounted.measureUserRow(targetMessageId); - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: measurement.timelineWidthMeasuredPx }, - ); - - expect(measurement.renderedInVirtualizedRegion).toBe(true); - expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, - ); - measurements.push({ ...measurement, viewport, estimatedHeightPx }); - } - - expect( - new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))) - .size, - ).toBeGreaterThanOrEqual(3); - - const byMeasuredWidth = measurements.toSorted( - (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, - ); - const narrowest = byMeasuredWidth[0]!; - const widest = byMeasuredWidth.at(-1)!; - expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx); - expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx); - expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx); - } finally { - await mounted.cleanup(); - } - }); - - it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => { - const userText = "x".repeat(2_400); - const targetMessageId = "msg-user-target-wrap" as MessageId; - const snapshot = createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }); - const desktopMeasurement = await measureUserRowAtViewport({ - viewport: TEXT_VIEWPORT_MATRIX[0], - snapshot, - targetMessageId, - }); - const mobileMeasurement = await measureUserRowAtViewport({ - viewport: TEXT_VIEWPORT_MATRIX[2], - snapshot, - targetMessageId, - }); - - const estimatedDesktopPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx }, - ); - const estimatedMobilePx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, - ); - - const measuredDeltaPx = - mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; - const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; - expect(measuredDeltaPx).toBeGreaterThan(0); - expect(estimatedDeltaPx).toBeGreaterThan(0); - const ratio = estimatedDeltaPx / measuredDeltaPx; - expect(ratio).toBeGreaterThan(0.65); - expect(ratio).toBeLessThan(1.35); - }); - - it.each(ATTACHMENT_VIEWPORT_MATRIX)( - "keeps user attachment estimate close at the $name viewport", - async (viewport) => { - const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId; - const userText = "message with image attachments"; - const mounted = await mountChatView({ - viewport, - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - targetAttachmentCount: 2, - }), - }); - - try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); - - expect(renderedInVirtualizedRegion).toBe(true); - - const estimatedHeightPx = estimateTimelineMessageHeight( - { - role: "user", - text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], - }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); - - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.attachmentTolerancePx, - ); - } finally { - await mounted.cleanup(); - } - }, - ); - it("shows an explicit empty state for projects without threads in the sidebar", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -2038,6 +2053,55 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows Kiro in the open picker menu and opens the project cwd with it", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["kiro"], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + const menuButton = await waitForElement( + () => document.querySelector('button[aria-label="Copy options"]'), + "Unable to find Open picker button.", + ); + (menuButton as HTMLButtonElement).click(); + + const kiroItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("Kiro"), + ) ?? null, + "Unable to find Kiro menu item.", + ); + (kiroItem as HTMLElement).click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "kiro", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("filters the open picker menu and opens VSCodium from the menu", async () => { setDraftThreadWithoutWorktree(); @@ -2367,16 +2431,16 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); const branchInput = await waitForElement( - () => document.querySelector('input[placeholder="Search branches..."]'), - "Unable to find branch search input.", + () => document.querySelector('input[placeholder="Search refs..."]'), + "Unable to find ref search input.", ); branchInput.focus(); - await page.getByPlaceholder("Search branches...").fill("1359"); + await page.getByPlaceholder("Search refs...").fill("1359"); const checkoutItem = await waitForElement( () => Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "Checkout Pull Request", + (element) => element.textContent?.trim() === "Checkout pull request", ) as HTMLSpanElement | null, "Unable to find checkout pull request option.", ); @@ -2505,7 +2569,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( + expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( false, ); expect( @@ -2521,56 +2585,87 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("shows the send state once bootstrap dispatch is in flight", async () => { - useTerminalStateStore.setState({ - terminalStateByThreadKey: {}, - }); - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - let resolveDispatch!: (value: { sequence: number }) => void; - const dispatchPromise = new Promise<{ sequence: number }>((resolve) => { - resolveDispatch = resolve; - }); + it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { + setDraftThreadWithoutWorktree(); + const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); + const openRouterSelection = createModelSelection(openRouterInstanceId, "openai/gpt-5.5"); + useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]), + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + ...nextFixture.serverConfig.providers, + { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + version: "2.1.117", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: openRouterInstanceId, + displayName: "Claude OpenRouter", + enabled: true, + installed: true, + version: "2.1.117", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + ], + settings: { + ...nextFixture.serverConfig.settings, + providerInstances: { + ...nextFixture.serverConfig.settings.providerInstances, + [openRouterInstanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude OpenRouter", + config: { customModels: ["openai/gpt-5.5"] }, + }, + }, + }, + }; + }, resolveRpc: (body) => { if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { - return dispatchPromise; + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; } return undefined; }, }); try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Hello there"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2579,77 +2674,122 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - expect( - wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), - ).toBe(true); - expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); - expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); + const turnStartRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ) as + | { + modelSelection?: { instanceId?: string; model?: string }; + bootstrap?: { + createThread?: { + modelSelection?: { instanceId?: string; model?: string }; + }; + }; + } + | undefined; + + expect(turnStartRequest?.modelSelection).toMatchObject({ + instanceId: openRouterInstanceId, + model: "openai/gpt-5.5", + }); + expect(turnStartRequest?.bootstrap?.createThread?.modelSelection).toMatchObject({ + instanceId: openRouterInstanceId, + model: "openai/gpt-5.5", + }); }, { timeout: 8_000, interval: 16 }, ); } finally { - resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 }); await mounted.cleanup(); } }); - it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { + it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => { + const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-target-hotkey" as MessageId, - targetText: "hotkey target", - }), + snapshot: { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread, + ), + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.vcsListRefs) { + return { + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + ], + }; + } + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, }); try { - const initialModeButton = await waitForInteractionModeButton("Build"); - expect(initialModeButton.title).toContain("enter plan mode"); - - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); - await waitForLayout(); - - expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); - - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); + (await waitForButtonByText("Current checkout")).click(); + await page.getByText("New worktree", { exact: true }).click(); await vi.waitFor( - async () => { - expect((await waitForInteractionModeButton("Plan")).title).toContain( - "return to normal build mode", - ); + () => { + expect(findButtonByText("New worktree")).toBeTruthy(); }, { timeout: 8_000, interval: 16 }, ); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Tab", - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); await vi.waitFor( - async () => { - expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); + () => { + const turnStartRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ) as + | { + _tag: string; + type?: string; + bootstrap?: { + createThread?: { projectId?: string }; + prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; + runSetupScript?: boolean; + }; + } + | undefined; + + expect(turnStartRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "thread.turn.start", + bootstrap: { + prepareWorktree: { + projectCwd: "/repo/project", + baseBranch: "main", + branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), + }, + runSetupScript: true, + }, + }); + expect(turnStartRequest?.bootstrap?.createThread).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); @@ -2658,55 +2798,24 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("uses the active draft route session when changing the base branch", async () => { - const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); - const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); - - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [staleDraftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - [activeDraftId]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, - [PROJECT_DRAFT_KEY]: activeDraftId, - }, - }); - + it("updates the selected worktree base branch on empty server threads", async () => { + const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createDraftOnlySnapshot(), - initialPath: `/draft/${activeDraftId}`, + snapshot: { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread, + ), + }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 2, - branches: [ + refs: [ { name: "main", current: true, @@ -2722,190 +2831,648 @@ describe("ChatView timeline estimator parity (full app)", () => { ], }; } + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } return undefined; }, }); try { - const branchButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "From main", - ) as HTMLButtonElement | null, - 'Unable to find branch selector button with "From main".', - ); - branchButton.click(); - - const branchOption = await waitForElement( - () => - Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "release/next", - ) as HTMLSpanElement | null, - 'Unable to find the "release/next" branch option.', - ); - branchOption.click(); + (await waitForButtonByText("Current checkout")).click(); + await page.getByText("New worktree", { exact: true }).click(); + await page.getByText("From main", { exact: true }).click(); + await page.getByText("release/next", { exact: true }).click(); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( - "release/next", - ); - expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( - "main", - ); + expect(findButtonByText("From release/next")).toBeTruthy(); }, { timeout: 8_000, interval: 16 }, ); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + await vi.waitFor( () => { - const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.trim().includes("From release/next"), - ); - expect(updatedButton).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-basic" as MessageId, - targetText: "surround basic", - }), - }); - - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); - await waitForComposerText("selected"); - await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length }); - await pressComposerKey("("); - await waitForComposerText("(selected)"); + const turnStartRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ) as + | { + _tag: string; + type?: string; + bootstrap?: { + prepareWorktree?: { baseBranch?: string }; + }; + } + | undefined; - await pressComposerKey("["); - await waitForComposerText("([selected])"); + expect(turnStartRequest?.bootstrap?.prepareWorktree?.baseBranch).toBe("release/next"); + }, + { timeout: 8_000, interval: 16 }, + ); } finally { await mounted.cleanup(); } }); - it("leaves collapsed-caret typing unchanged for surround symbols", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); - + it("clears pending worktree overrides when switching empty server threads", async () => { + const secondThreadId = "thread-browser-test-second" as ThreadId; + const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID); + const snapshotWithSecondThread = addThreadToSnapshot(snapshot, secondThreadId); + const snapshotWithTwoThreads = { + ...snapshotWithSecondThread, + threads: snapshotWithSecondThread.threads.map((thread) => { + if (thread.id === THREAD_ID) { + return Object.assign({}, thread, { session: null, title: "Thread alpha" }); + } + if (thread.id === secondThreadId) { + return Object.assign({}, thread, { session: null, title: "Thread beta" }); + } + return thread; + }), + }; const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-collapsed" as MessageId, - targetText: "surround collapsed", - }), + snapshot: snapshotWithTwoThreads, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.vcsListRefs) { + return { + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 2, + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + { + name: "release/next", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + }; + } + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, }); try { - await waitForComposerText("selected"); - await setComposerSelectionByTextOffsets({ - start: "selected".length, - end: "selected".length, + (await waitForButtonByText("Current checkout")).click(); + await page.getByText("New worktree", { exact: true }).click(); + await page.getByText("From main", { exact: true }).click(); + await page.getByText("release/next", { exact: true }).click(); + + await vi.waitFor( + () => { + expect(findButtonByText("From release/next")).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + + await mounted.router.navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: LOCAL_ENVIRONMENT_ID, + threadId: secondThreadId, + }, }); - await pressComposerKey("("); - await waitForComposerText("selected("); - } finally { - await mounted.cleanup(); - } - }); - it("supports symmetric and backward-selection surrounds", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "backward"); + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(secondThreadId), + "Route should switch to the second empty server thread.", + ); - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-backward" as MessageId, - targetText: "surround backward", - }), - }); + await vi.waitFor( + () => { + expect(findButtonByText("Current checkout")).toBeTruthy(); + expect(findButtonByText("From release/next")).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); - try { - await waitForComposerText("backward"); - await setComposerSelectionByTextOffsets({ - start: 0, - end: "backward".length, - direction: "backward", - }); - await pressComposerKey("*"); - await waitForComposerText("*backward*"); + (await waitForButtonByText("Current checkout")).click(); + await page.getByText("New worktree", { exact: true }).click(); + + await vi.waitFor( + () => { + expect(findButtonByText("From main")).toBeTruthy(); + expect(findButtonByText("From release/next")).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); } finally { await mounted.cleanup(); } }); - it("supports option-produced surround symbols like guillemets", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); + it("shows the send state once bootstrap dispatch is in flight", async () => { + useTerminalStateStore.setState({ + terminalStateByThreadKey: {}, + }); + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, + }, + }); + + let resolveDispatch!: (value: { sequence: number }) => void; + const dispatchPromise = new Promise<{ sequence: number }>((resolve) => { + resolveDispatch = resolve; + }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-guillemet" as MessageId, - targetText: "surround guillemet", - }), + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return dispatchPromise; + } + return undefined; + }, }); try { - await waitForComposerText("quoted"); - await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); - await pressComposerKey("«"); - await waitForComposerText("«quoted»"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + expect( + wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), + ).toBe(true); + expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); + expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); } finally { + resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 }); await mounted.cleanup(); } }); - it("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); - + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-dead-quote" as MessageId, - targetText: "surround dead quote", + targetMessageId: "msg-user-target-hotkey" as MessageId, + targetText: "hotkey target", }), }); try { - await waitForComposerText("quoted"); - await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); + const initialModeButton = await waitForInteractionModeButton("Build"); + expect(initialModeButton.title).toContain("enter plan mode"); + + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + await waitForLayout(); + + expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); + const composerEditor = await waitForComposerEditor(); composerEditor.focus(); composerEditor.dispatchEvent( new KeyboardEvent("keydown", { - key: "Dead", + key: "Tab", + shiftKey: true, bubbles: true, cancelable: true, }), ); + + await vi.waitFor( + async () => { + expect((await waitForInteractionModeButton("Plan")).title).toContain( + "return to normal build mode", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + composerEditor.dispatchEvent( - new InputEvent("beforeinput", { - data: "'", - inputType: "insertCompositionText", + new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, bubbles: true, cancelable: true, }), ); - const resolvedInputEvent = new InputEvent("beforeinput", { - data: "'", - inputType: "insertText", - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(resolvedInputEvent); - expect(resolvedInputEvent.defaultPrevented).toBe(true); - await waitForComposerText("'quoted'"); - await pressComposerUndo(); - await waitForComposerText("quoted"); + + await vi.waitFor( + async () => { + expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("uses the active draft route session when changing the base branch", async () => { + const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session"); + const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session"); + + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [staleDraftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + [activeDraftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId, + [PROJECT_DRAFT_KEY]: activeDraftId, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${activeDraftId}`, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.vcsListRefs) { + return { + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 2, + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + { + name: "release/next", + current: false, + isDefault: false, + worktreePath: null, + }, + ], + }; + } + return undefined; + }, + }); + + try { + const branchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "From main", + ) as HTMLButtonElement | null, + 'Unable to find branch selector button with "From main".', + ); + branchButton.click(); + + const branchOption = await waitForElement( + () => + Array.from(document.querySelectorAll("span")).find( + (element) => element.textContent?.trim() === "release/next", + ) as HTMLSpanElement | null, + 'Unable to find the "release/next" branch option.', + ); + branchOption.click(); + + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe( + "release/next", + ); + expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe( + "main", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const updatedButton = Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.trim().includes("From release/next"), + ); + expect(updatedButton).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the new worktree branch picker anchored at the top when opening with a preselected branch", async () => { + const draftId = DraftId.make("draft-branch-picker-scroll-regression"); + const branches = [ + { + name: "feature/current", + current: true, + isDefault: false, + worktreePath: null, + }, + { + name: "main", + current: false, + isDefault: true, + worktreePath: null, + }, + ...Array.from({ length: 48 }, (_, index) => ({ + name: `feature/${String(index).padStart(2, "0")}`, + current: false, + isDefault: false, + worktreePath: null, + })), + { + name: "feature/selected", + current: false, + isDefault: false, + worktreePath: null, + }, + ]; + + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [draftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/selected", + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: draftId, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${draftId}`, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.vcsListRefs) { + return { + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: branches.length, + refs: branches, + }; + } + return undefined; + }, + }); + + try { + const branchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "From feature/selected", + ) as HTMLButtonElement | null, + 'Unable to find branch selector button with "From feature/selected".', + ); + branchButton.click(); + + await waitForElement( + () => document.querySelector('input[placeholder="Search refs..."]'), + "Unable to find ref search input.", + ); + + const popup = await waitForElement( + () => document.querySelector('[data-slot="combobox-popup"]'), + "Unable to find the branch picker popup.", + ); + + await vi.waitFor( + () => { + const popupSpans = Array.from(popup.querySelectorAll("span")); + expect( + popupSpans.some((element) => element.textContent?.trim() === "feature/current"), + ).toBe(true); + expect(popupSpans.some((element) => element.textContent?.trim() === "main")).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-basic" as MessageId, + targetText: "surround basic", + }), + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); + await waitForComposerText("selected"); + await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length }); + await pressComposerKey("("); + await waitForComposerText("(selected)"); + + await pressComposerKey("["); + await waitForComposerText("([selected])"); + } finally { + await mounted.cleanup(); + } + }); + + it("leaves collapsed-caret typing unchanged for surround symbols", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-collapsed" as MessageId, + targetText: "surround collapsed", + }), + }); + + try { + await waitForComposerText("selected"); + await setComposerSelectionByTextOffsets({ + start: "selected".length, + end: "selected".length, + }); + await pressComposerKey("("); + await waitForComposerText("selected("); + } finally { + await mounted.cleanup(); + } + }); + + it("supports symmetric and backward-selection surrounds", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "backward"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-backward" as MessageId, + targetText: "surround backward", + }), + }); + + try { + await waitForComposerText("backward"); + await setComposerSelectionByTextOffsets({ + start: 0, + end: "backward".length, + direction: "backward", + }); + await pressComposerKey("*"); + await waitForComposerText("*backward*"); + } finally { + await mounted.cleanup(); + } + }); + + it("supports option-produced surround symbols like guillemets", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-guillemet" as MessageId, + targetText: "surround guillemet", + }), + }); + + try { + await waitForComposerText("quoted"); + await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); + await pressComposerKey("«"); + await waitForComposerText("«quoted»"); + } finally { + await mounted.cleanup(); + } + }); + + it("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-dead-quote" as MessageId, + targetText: "surround dead quote", + }), + }); + + try { + await waitForComposerText("quoted"); + await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Dead", + bubbles: true, + cancelable: true, + }), + ); + composerEditor.dispatchEvent( + new InputEvent("beforeinput", { + data: "'", + inputType: "insertCompositionText", + bubbles: true, + cancelable: true, + }), + ); + const resolvedInputEvent = new InputEvent("beforeinput", { + data: "'", + inputType: "insertText", + bubbles: true, + cancelable: true, + }); + composerEditor.dispatchEvent(resolvedInputEvent); + expect(resolvedInputEvent.defaultPrevented).toBe(true); + await waitForComposerText("'quoted'"); + await pressComposerUndo(); + await waitForComposerText("quoted"); } finally { await mounted.cleanup(); } @@ -2917,808 +3484,1734 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-after-mention" as MessageId, - targetText: "surround after mention", + targetMessageId: "msg-user-surround-after-mention" as MessageId, + targetText: "surround after mention", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("package.json"); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForComposerText("hi @package.json there"); + await setComposerSelectionByTextOffsets({ + start: "hi package.json ".length, + end: "hi package.json there".length, + }); + await pressComposerKey("("); + await waitForComposerText("hi @package.json (there)"); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to normal replacement when the selection includes a mention token", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there "); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-token" as MessageId, + targetText: "surround token", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("package.json"); + }, + { timeout: 8_000, interval: 16 }, + ); + await selectAllComposerContent(); + await pressComposerKey("("); + await waitForComposerText("("); + } finally { + await mounted.cleanup(); + } + }); + + it("shows runtime mode descriptions in the desktop composer access select", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + }); + + try { + const runtimeModeSelect = await waitForButtonByText("Full access"); + runtimeModeSelect.click(); + + expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain( + "Ask before commands and file changes", + ); + + const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits"); + expect(autoAcceptItem.textContent).toContain("Auto-approve edits"); + expect((await waitForSelectItemContainingText("Full access")).textContent).toContain( + "Allow commands and edits without prompts", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps removed terminal context pills removed when a new one is added", async () => { + const removedLabel = "Terminal 1 lines 1-2"; + const addedLabel = "Terminal 2 lines 9-10"; + useComposerDraftStore.getState().addTerminalContext( + THREAD_REF, + createTerminalContext({ + id: "ctx-removed", + terminalLabel: "Terminal 1", + lineStart: 1, + lineEnd: 2, + text: "bun i\nno changes", + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, + targetText: "terminal pill backspace target", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain(removedLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + const store = useComposerDraftStore.getState(); + const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; + const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); + store.setPrompt(THREAD_REF, nextPrompt.prompt); + store.removeTerminalContext(THREAD_REF, "ctx-removed"); + + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); + expect(document.body.textContent).not.toContain(removedLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + useComposerDraftStore.getState().addTerminalContext( + THREAD_REF, + createTerminalContext({ + id: "ctx-added", + terminalLabel: "Terminal 2", + lineStart: 9, + lineEnd: 10, + text: "git status\nOn branch main", + }), + ); + + await vi.waitFor( + () => { + const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; + expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); + expect(document.body.textContent).toContain(addedLabel); + expect(document.body.textContent).not.toContain(removedLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("disables send when the composer only contains an expired terminal pill", async () => { + const expiredLabel = "Terminal 1 line 4"; + useComposerDraftStore.getState().addTerminalContext( + THREAD_REF, + createTerminalContext({ + id: "ctx-expired-only", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-expired-pill-disabled" as MessageId, + targetText: "expired pill disabled target", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain(expiredLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(true); + } finally { + await mounted.cleanup(); + } + }); + + it("warns when sending text while omitting expired terminal pills", async () => { + const expiredLabel = "Terminal 1 line 4"; + useComposerDraftStore.getState().addTerminalContext( + THREAD_REF, + createTerminalContext({ + id: "ctx-expired-send-warning", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + }), + ); + useComposerDraftStore + .getState() + .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-expired-pill-warning" as MessageId, + targetText: "expired pill warning target", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain(expiredLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain( + "Expired terminal context omitted from message", + ); + expect(document.body.textContent).not.toContain(expiredLabel); + expect(document.body.textContent).toContain("yoowaddup"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows a pointer cursor for the running stop button", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-stop-button-cursor" as MessageId, + targetText: "stop button cursor target", + sessionStatus: "running", + }), + }); + + try { + const stopButton = await waitForElement( + () => document.querySelector('button[aria-label="Stop generation"]'), + "Unable to find stop generation button.", + ); + + expect(getComputedStyle(stopButton).cursor).toBe("pointer"); + } finally { + await mounted.cleanup(); + } + }); + + it("hides the archive action when the pointer leaves a thread row", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-archive-hover-test" as MessageId, + targetText: "archive hover target", + }), + }); + + try { + const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); + + await expect.element(threadRow).toBeInTheDocument(); + const archiveButton = await waitForElement( + () => + document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), + "Unable to find archive button.", + ); + const archiveAction = archiveButton.parentElement; + expect( + archiveAction, + "Archive button should render inside a visibility wrapper.", + ).not.toBeNull(); + expect(getComputedStyle(archiveAction!).opacity).toBe("0"); + + await threadRow.hover(); + await vi.waitFor( + () => { + expect(getComputedStyle(archiveAction!).opacity).toBe("1"); + }, + { timeout: 4_000, interval: 16 }, + ); + + await page.getByTestId("composer-editor").hover(); + await vi.waitFor( + () => { + expect(getComputedStyle(archiveAction!).opacity).toBe("0"); + }, + { timeout: 4_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("exposes the full thread title on the sidebar row tooltip", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-thread-tooltip-target" as MessageId, + targetText: "thread tooltip target", + }), + }); + + try { + const threadTitle = page.getByTestId(`thread-title-${THREAD_ID}`); + + await expect.element(threadTitle).toBeInTheDocument(); + await threadTitle.hover(); + + await vi.waitFor( + () => { + const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); + expect(tooltip).not.toBeNull(); + expect(tooltip?.textContent).toContain(THREAD_TITLE); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the confirm archive action after clicking the archive button", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + confirmThreadArchive: true, + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-archive-confirm-test" as MessageId, + targetText: "archive confirm target", + }), + }); + + try { + const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); + + await expect.element(threadRow).toBeInTheDocument(); + await threadRow.hover(); + + const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); + await expect.element(archiveButton).toBeInTheDocument(); + await archiveButton.click(); + + const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`); + await expect.element(confirmButton).toBeInTheDocument(); + await expect.element(confirmButton).toBeVisible(); + } finally { + localStorage.removeItem("t3code:client-settings:v1"); + await mounted.cleanup(); + } + }); + + it("canonicalizes promoted draft threads to the server thread route", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-new-thread-test" as MessageId, + targetText: "new thread selection test", + }), + }); + + try { + // Wait for the sidebar to render with the project. + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + // The route should change to a new draft thread ID. + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); + + // The composer editor should be present for the new draft thread. + await waitForComposerEditor(); + + // `thread.created` should only mark the draft as promoting; it should + // not navigate away until the server thread has actual runtime state. + await materializePromotedDraftThreadViaDomainEvent(newThreadId); + expect(mounted.router.state.location.pathname).toBe(newThreadPath); + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + + // Once the server thread starts, the route should canonicalize. + await startPromotedServerThreadViaDomainEvent(newThreadId); + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( + undefined, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + // The route should switch to the canonical server thread path. + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(newThreadId), + "Promoted drafts should canonicalize to the server thread route.", + ); + + // The composer should remain usable after canonicalization, regardless of + // whether the promoted thread is still visibly empty or has already + // entered the running state. + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("canonicalizes stale promoted draft routes to the server thread route", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, + targetText: "draft hydration race test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); + + await promoteDraftThreadViaDomainEvent(newThreadId); + + await mounted.router.navigate({ + to: "/draft/$draftId", + params: { draftId: newDraftId }, + }); + + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(newThreadId), + "Stale promoted draft routes should canonicalize to the server thread path.", + ); + + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a fresh worktree draft from an existing worktree thread when the default mode is worktree", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...createSnapshotForTargetUser({ + targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, + targetText: "new thread worktree default test", + }), + threads: createSnapshotForTargetUser({ + targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, + targetText: "new thread worktree default test", + }).threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + branch: "feature/existing", + worktreePath: "/repo/.t3/worktrees/existing", + }) + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + }; + }, + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should change to a new draft thread.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + + expect(useComposerDraftStore.getState().getDraftSession(newDraftId)).toMatchObject({ + envMode: "worktree", + worktreePath: null, + }); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a new draft instead of reusing a promoting draft thread", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-promoting-draft-new-thread-test" as MessageId, + targetText: "promoting draft new thread test", }), }); try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("package.json"); - }, - { timeout: 8_000, interval: 16 }, + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const firstDraftPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should change to the first draft thread.", ); - await waitForComposerText("hi @package.json there"); - await setComposerSelectionByTextOffsets({ - start: "hi package.json ".length, - end: "hi package.json there".length, - }); - await pressComposerKey("("); - await waitForComposerText("hi @package.json (there)"); + const firstDraftId = draftIdFromPath(firstDraftPath); + const firstThreadId = draftThreadIdFor(firstDraftId); + + await materializePromotedDraftThreadViaDomainEvent(firstThreadId); + expect(mounted.router.state.location.pathname).toBe(firstDraftPath); + + await newThreadButton.click(); + + const secondDraftPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== firstDraftPath, + "Route should change to a second draft thread instead of reusing the promoting draft.", + ); + expect(draftIdFromPath(secondDraftPath)).not.toBe(firstDraftId); } finally { await mounted.cleanup(); } }); - it("falls back to normal replacement when the selection includes a mention token", async () => { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there "); + it("snapshots sticky codex settings into a new draft thread", async () => { + useComposerDraftStore.setState({ + stickyModelSelectionByProvider: { + [ProviderInstanceId.make("codex")]: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.3-codex", + [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ], + ), + }, + stickyActiveProvider: ProviderInstanceId.make("codex"), + }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-surround-token" as MessageId, - targetText: "surround token", + targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, + targetText: "sticky codex traits test", }), }); try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain("package.json"); - }, - { timeout: 8_000, interval: 16 }, + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", ); - await selectAllComposerContent(); - await pressComposerKey("("); - await waitForComposerText("("); + const newDraftId = draftIdFromPath(newThreadPath); + + // `toMatchObject` matches objects loosely (extras ignored) but compares + // arrays strictly, so wrap `options` in `arrayContaining` to keep the + // assertion focused on sticky `fastMode` carrying over without asserting + // on exactly which other options are preserved. + expect(composerDraftFor(newDraftId)).toMatchObject({ + modelSelectionByProvider: { + codex: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex", + options: expect.arrayContaining([{ id: "fastMode", value: true }]), + }, + }, + activeProvider: "codex", + }); } finally { await mounted.cleanup(); } }); - it("shows runtime mode descriptions in the desktop composer access select", async () => { - setDraftThreadWithoutWorktree(); + it("hydrates the provider alongside a sticky claude model", async () => { + useComposerDraftStore.setState({ + stickyModelSelectionByProvider: { + [ProviderInstanceId.make("claudeAgent")]: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), + }, + stickyActiveProvider: ProviderInstanceId.make("claudeAgent"), + }); const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createDraftOnlySnapshot(), + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, + targetText: "sticky claude model test", + }), }); try { - const runtimeModeSelect = await waitForButtonByText("Full access"); - runtimeModeSelect.click(); + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); - expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain( - "Ask before commands and file changes", - ); + await newThreadButton.click(); - const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits"); - expect(autoAcceptItem.textContent).toContain("Auto-approve edits"); - expect((await waitForSelectItemContainingText("Full access")).textContent).toContain( - "Allow commands and edits without prompts", + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new sticky claude draft thread UUID.", ); + const newDraftId = draftIdFromPath(newThreadPath); + + expect(composerDraftFor(newDraftId)).toMatchObject({ + modelSelectionByProvider: { + claudeAgent: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), + }, + activeProvider: "claudeAgent", + }); } finally { await mounted.cleanup(); } }); - it("keeps removed terminal context pills removed when a new one is added", async () => { - const removedLabel = "Terminal 1 lines 1-2"; - const addedLabel = "Terminal 2 lines 9-10"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-removed", - terminalLabel: "Terminal 1", - lineStart: 1, - lineEnd: 2, - text: "bun i\nno changes", - }), - ); - + it("falls back to defaults when no sticky composer settings exist", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, - targetText: "terminal pill backspace target", + targetMessageId: "msg-user-default-codex-traits-test" as MessageId, + targetText: "default codex traits test", }), }); try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; - const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_REF, nextPrompt.prompt); - store.removeTerminalContext(THREAD_REF, "ctx-removed"); + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); + await newThreadButton.click(); - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-added", - terminalLabel: "Terminal 2", - lineStart: 9, - lineEnd: 10, - text: "git status\nOn branch main", - }), + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", ); + const newDraftId = draftIdFromPath(newThreadPath); - await vi.waitFor( - () => { - const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; - expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); - expect(document.body.textContent).toContain(addedLabel); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); + expect(composerDraftFor(newDraftId)).toBe(undefined); } finally { await mounted.cleanup(); } }); - it("disables send when the composer only contains an expired terminal pill", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-only", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); + it("prefers draft state over sticky composer settings and defaults", async () => { + useComposerDraftStore.setState({ + stickyModelSelectionByProvider: { + [ProviderInstanceId.make("codex")]: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.3-codex", + [ + { id: "reasoningEffort", value: "medium" }, + { id: "fastMode", value: true }, + ], + ), + }, + stickyActiveProvider: ProviderInstanceId.make("codex"), + }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-disabled" as MessageId, - targetText: "expired pill disabled target", + targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, + targetText: "draft codex traits precedence test", }), }); try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const threadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a sticky draft thread UUID.", + ); + const draftId = draftIdFromPath(threadPath); + + // See the note on the sibling sticky-codex test: arrays match strictly + // under `toMatchObject`, so use `arrayContaining` to keep the assertion + // scoped to the sticky trait (`fastMode`) that must carry over. + expect(composerDraftFor(draftId)).toMatchObject({ + modelSelectionByProvider: { + codex: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex", + options: expect.arrayContaining([{ id: "fastMode", value: true }]), + }, }, - { timeout: 8_000, interval: 16 }, + activeProvider: "codex", + }); + + useComposerDraftStore.getState().setModelSelection( + draftId, + createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ]), + ); + + await newThreadButton.click(); + + await waitForURL( + mounted.router, + (path) => path === threadPath, + "New-thread should reuse the existing project draft thread.", ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(true); + expect(composerDraftFor(draftId)).toMatchObject({ + modelSelectionByProvider: { + codex: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "low" }, + { id: "fastMode", value: true }, + ]), + }, + activeProvider: "codex", + }); } finally { await mounted.cleanup(); } }); - it("warns when sending text while omitting expired terminal pills", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-send-warning", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - useComposerDraftStore - .getState() - .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); - + it("creates a new thread from the global chat.new shortcut", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-warning" as MessageId, - targetText: "expired pill warning target", + targetMessageId: "msg-user-chat-shortcut-test" as MessageId, + targetText: "chat shortcut test", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + { + command: "thread.jump.1", + shortcut: { + key: "1", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, + { + command: "modelPicker.jump.1", + shortcut: { + key: "1", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "modelPickerOpen" }, + }, + ], + }; + }, }); try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Expired terminal context omitted from message", - ); - expect(document.body.textContent).not.toContain(expiredLabel); - expect(document.body.textContent).toContain("yoowaddup"); - }, - { timeout: 8_000, interval: 16 }, + await waitForNewThreadShortcutLabel(); + await waitForServerConfigToApply(); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + await waitForLayout(); + await triggerChatNewShortcutUntilPath( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the shortcut.", ); } finally { await mounted.cleanup(); } }); - it("shows a pointer cursor for the running stop button", async () => { + it("does not consume chat.new when there is no project context", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-stop-button-cursor" as MessageId, - targetText: "stop button cursor target", - sessionStatus: "running", - }), + snapshot: createProjectlessSnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, }); try { - const stopButton = await waitForElement( - () => document.querySelector('button[aria-label="Stop generation"]'), - "Unable to find stop generation button.", - ); + await waitForServerConfigToApply(); + dispatchChatNewShortcut(); + await waitForLayout(); - expect(getComputedStyle(stopButton).cursor).toBe("pointer"); + expect(mounted.router.state.location.pathname).toBe(serverThreadPath(THREAD_ID)); + expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadKey)).toHaveLength(0); } finally { await mounted.cleanup(); } }); - it("hides the archive action when the pointer leaves a thread row", async () => { + it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-archive-hover-test" as MessageId, - targetText: "archive hover target", + targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, + targetText: "command palette shortcut test", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, }); try { - const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); - - await expect.element(threadRow).toBeInTheDocument(); - const archiveButton = await waitForElement( - () => - document.querySelector(`[data-testid="thread-archive-${THREAD_ID}"]`), - "Unable to find archive button.", - ); - const archiveAction = archiveButton.parentElement; - expect( - archiveAction, - "Archive button should render inside a visibility wrapper.", - ).not.toBeNull(); - expect(getComputedStyle(archiveAction!).opacity).toBe("0"); + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); - await threadRow.hover(); - await vi.waitFor( - () => { - expect(getComputedStyle(archiveAction!).opacity).toBe("1"); - }, - { timeout: 4_000, interval: 16 }, - ); + await expect.element(palette).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("New thread in Project", { exact: true }).click(); - await page.getByTestId("composer-editor").hover(); - await vi.waitFor( - () => { - expect(getComputedStyle(archiveAction!).opacity).toBe("0"); - }, - { timeout: 4_000, interval: 16 }, + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the command palette.", ); } finally { await mounted.cleanup(); } }); - it("shows the confirm archive action after clicking the archive button", async () => { - localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - ...DEFAULT_CLIENT_SETTINGS, - confirmThreadArchive: true, - }), - ); - + it("filters command palette results as the user types", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-archive-confirm-test" as MessageId, - targetText: "archive confirm target", + targetMessageId: "msg-user-command-palette-search-test" as MessageId, + targetText: "command palette search test", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, }); try { - const threadRow = page.getByTestId(`thread-row-${THREAD_ID}`); - - await expect.element(threadRow).toBeInTheDocument(); - await threadRow.hover(); - - const archiveButton = page.getByTestId(`thread-archive-${THREAD_ID}`); - await expect.element(archiveButton).toBeInTheDocument(); - await archiveButton.click(); + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); - const confirmButton = page.getByTestId(`thread-archive-confirm-${THREAD_ID}`); - await expect.element(confirmButton).toBeInTheDocument(); - await expect.element(confirmButton).toBeVisible(); + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); + await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .not.toBeInTheDocument(); } finally { - localStorage.removeItem("t3code:client-settings:v1"); await mounted.cleanup(); } }); - it("canonicalizes promoted draft threads to the server thread route", async () => { + it("adds a project from browse mode with Enter when no directory is highlighted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-thread-test" as MessageId, - targetText: "new thread selection test", + targetMessageId: "msg-user-command-palette-add-project-enter" as MessageId, + targetText: "command palette add project enter", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [ + { name: "alpha", fullPath: "~/Development/alpha" }, + { name: "beta", fullPath: "~/Development/beta" }, + ], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, }); try { - // Wait for the sidebar to render with the project. - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); - await newThreadButton.click(); + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); - // The route should change to a new draft thread ID. - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); - const newThreadId = draftThreadIdFor(newDraftId); + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); + await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); - // The composer editor should be present for the new draft thread. - await waitForComposerEditor(); + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); - // `thread.created` should only mark the draft as promoting; it should - // not navigate away until the server thread has actual runtime state. - await materializePromotedDraftThreadViaDomainEvent(newThreadId); - expect(mounted.router.state.location.pathname).toBe(newThreadPath); - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + await dispatchInputKey(browseInput, { key: "Enter" }); - // Once the server thread starts, the route should canonicalize. - await startPromotedServerThreadViaDomainEvent(newThreadId); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( - undefined, - ); + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development", + title: "Development", + }); }, { timeout: 8_000, interval: 16 }, ); - // The route should switch to the canonical server thread path. await waitForURL( mounted.router, - (path) => path === serverThreadPath(newThreadId), - "Promoted drafts should canonicalize to the server thread route.", + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project with Enter.", ); - - // The composer should remain usable after canonicalization, regardless of - // whether the promoted thread is still visibly empty or has already - // entered the running state. - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); } finally { await mounted.cleanup(); } }); - it("canonicalizes stale promoted draft routes to the server thread route", async () => { + it("shows clone destination controls after resolving an add project repository", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, - targetText: "draft hydration race test", + targetMessageId: "msg-user-command-palette-add-project-remote" as MessageId, + targetText: "command palette add project remote", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === WS_METHODS.sourceControlLookupRepository) { + return { + provider: "github", + nameWithOwner: "t3-oss/t3-env", + url: "https://github.com/t3-oss/t3-env", + sshUrl: "git@github.com:t3-oss/t3-env.git", + }; + } + + if (body._tag === WS_METHODS.sourceControlCloneRepository) { + return { + cwd: body.destinationPath, + remoteUrl: body.remoteUrl, + repository: null, + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, }); try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); - await newThreadButton.click(); + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("GitHub repository", { exact: true }).click(); - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", + const repositoryInput = await waitForCommandPaletteInput( + "Enter GitHub repository (owner/repo)", ); - const newDraftId = draftIdFromPath(newThreadPath); - const newThreadId = draftThreadIdFor(newDraftId); - - await promoteDraftThreadViaDomainEvent(newThreadId); + await page.getByPlaceholder("Enter GitHub repository (owner/repo)").fill("t3-oss/t3-env"); + await dispatchInputKey(repositoryInput, { key: "Enter" }); - await mounted.router.navigate({ - to: "/draft/$draftId", - params: { draftId: newDraftId }, - }); + await vi.waitFor( + () => { + const clonePathInput = document.querySelector( + 'input[placeholder="Enter path (e.g. ~/projects/my-app)"]', + ); + expect(clonePathInput?.value).toBe("~/"); + expect(document.body.textContent).toContain("Repository"); + expect(document.body.textContent).toContain("t3-oss/t3-env"); + expect(document.body.textContent).toContain("https://github.com/t3-oss/t3-env"); + expect(document.body.textContent).toContain("Select where to clone"); + expect(document.body.textContent).toContain("Development"); + expect(document.body.textContent).toContain("Clone"); + }, + { timeout: 8_000, interval: 16 }, + ); - await waitForURL( - mounted.router, - (path) => path === serverThreadPath(newThreadId), - "Stale promoted draft routes should canonicalize to the server thread path.", + await page + .getByPlaceholder("Enter path (e.g. ~/projects/my-app)") + .fill("~/Development/t3env"); + const clonePathInput = await waitForCommandPaletteInput( + "Enter path (e.g. ~/projects/my-app)", ); + await dispatchInputKey(clonePathInput, { key: "Enter" }); - await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + await vi.waitFor( + () => { + const cloneRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.sourceControlCloneRepository, + ) as { destinationPath?: string; remoteUrl?: string } | undefined; + expect(cloneRequest).toMatchObject({ + remoteUrl: "git@github.com:t3-oss/t3-env.git", + destinationPath: "~/Development/t3env", + }); + }, + { timeout: 8_000, interval: 16 }, + ); } finally { await mounted.cleanup(); } }); - it("snapshots sticky codex settings into a new draft thread", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - }, - stickyActiveProvider: "codex", - }); - + it("opens add project browse mode from the sidebar add button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, - targetText: "sticky codex traits test", + targetMessageId: "msg-user-sidebar-add-project-trigger" as MessageId, + targetText: "sidebar add project trigger", }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + return undefined; + }, }); try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); + await waitForServerConfigToApply(); - await newThreadButton.click(); + await page.getByTestId("sidebar-add-project-trigger").click(); - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && request.partialPath === "~/", + ), + ).toBe(true); }, - activeProvider: "codex", - }); + { timeout: 8_000, interval: 16 }, + ); } finally { await mounted.cleanup(); } }); - it("hydrates the provider alongside a sticky claude model", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - claudeAgent: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, - }, - }, - stickyActiveProvider: "claudeAgent", - }); - + it("starts add project browse mode from the configured base directory", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, - targetText: "sticky claude model test", + targetMessageId: "msg-user-sidebar-add-project-custom-base-dir" as MessageId, + targetText: "sidebar add project custom base directory", }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + addProjectBaseDirectory: "~/Development", + }, + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [{ name: "codething", fullPath: "~/Development/codething" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + return undefined; + }, }); try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); + await waitForServerConfigToApply(); - await newThreadButton.click(); + await page.getByTestId("sidebar-add-project-trigger").click(); - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new sticky claude draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - claudeAgent: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, - }, + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/Development/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && + request.partialPath === "~/Development/", + ), + ).toBe(true); }, - activeProvider: "claudeAgent", - }); + { timeout: 8_000, interval: 16 }, + ); } finally { await mounted.cleanup(); } }); - it("falls back to defaults when no sticky composer settings exist", async () => { + it("shows create-folder affordances for missing project paths", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-default-codex-traits-test" as MessageId, - targetText: "default codex traits test", + targetMessageId: "msg-user-command-palette-create-missing-project" as MessageId, + targetText: "command palette create missing project", }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Desktop/") { + return { + parentPath: "~/Desktop/", + entries: [{ name: "existing", fullPath: "~/Desktop/existing" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Desktop", fullPath: "~/Desktop" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, }); try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); + await waitForServerConfigToApply(); + const palette = page.getByTestId("command-palette"); + await page.getByTestId("sidebar-add-project-trigger").click(); - await newThreadButton.click(); + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newDraftId = draftIdFromPath(newThreadPath); + await expect + .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) + .toBeInTheDocument(); + await expect.element(palette.getByText("Will create this folder")).not.toBeInTheDocument(); - expect(composerDraftFor(newDraftId)).toBe(undefined); + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + createWorkspaceRootIfMissing?: boolean; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Desktop/fresh-project", + title: "fresh-project", + createWorkspaceRootIfMissing: true, + }); + }, + { timeout: 8_000, interval: 16 }, + ); } finally { await mounted.cleanup(); } }); - it("prefers draft state over sticky composer settings and defaults", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - }, - stickyActiveProvider: "codex", - }); - + it("does not show create affordances for an existing directory with a trailing slash", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, - targetText: "draft codex traits precedence test", + targetMessageId: "msg-user-command-palette-existing-trailing-directory" as MessageId, + targetText: "command palette existing trailing directory", }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/codex/") { + return { + parentPath: "~/Development/codex/", + entries: [{ name: "Codex.app", fullPath: "~/Development/codex/Codex.app" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, }); try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); + await waitForServerConfigToApply(); + const palette = page.getByTestId("command-palette"); + await page.getByTestId("sidebar-add-project-trigger").click(); - await newThreadButton.click(); + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Local folder", { exact: true }).click(); + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); - const threadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a sticky draft thread UUID.", + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && + request.partialPath === "~/Development/codex/", + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, ); - const draftId = draftIdFromPath(threadPath); - expect(composerDraftFor(draftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, - }, - activeProvider: "codex", - }); + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); + await expect + .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) + .not.toBeInTheDocument(); - useComposerDraftStore.getState().setModelSelection(draftId, { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, - }); + await dispatchInputKey(browseInput, { key: "Enter" }); - await newThreadButton.click(); + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; - await waitForURL( - mounted.router, - (path) => path === threadPath, - "New-thread should reuse the existing project draft thread.", - ); - expect(composerDraftFor(draftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, - }, - }, + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development/codex", + title: "codex", + }); }, - activeProvider: "codex", - }); + { timeout: 8_000, interval: 16 }, + ); } finally { await mounted.cleanup(); } }); - it("creates a new thread from the global chat.new shortcut", async () => { + it("selects an environment before browsing when multiple environments are available", async () => { + const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => { + if (partialPath === "~/workspaces/") { + return { + parentPath: "~/workspaces/", + entries: [{ name: "codething", fullPath: "~/workspaces/codething" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "workspaces", fullPath: "~/workspaces" }], + }; + }); + const remoteDispatchMock = vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })); + + __setEnvironmentApiOverrideForTests( + REMOTE_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: remoteBrowseMock, + dispatchCommand: remoteDispatchMock, + }), + ); + const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-chat-shortcut-test" as MessageId, - targetText: "chat shortcut test", + targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId, + targetText: "command palette add project multi env", }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, }); try { - await waitForNewThreadShortcutLabel(); await waitForServerConfigToApply(); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - await waitForLayout(); - await triggerChatNewShortcutUntilPath( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the shortcut.", + useSavedEnvironmentRegistryStore.getState().upsert({ + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + httpBaseUrl: "https://staging.example.test", + wsBaseUrl: "wss://staging.example.test/ws", + createdAt: NOW_ISO, + lastConnectedAt: NOW_ISO, + }); + useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { + connectionState: "connected", + authState: "authenticated", + descriptor: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + }, + serverConfig: { + ...fixture.serverConfig, + environment: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + }, + settings: { + ...fixture.serverConfig.settings, + addProjectBaseDirectory: "~/workspaces", + }, + }, + connectedAt: NOW_ISO, + }); + + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("This device", { exact: true }).first()) + .toBeInTheDocument(); + await palette.getByText("Staging", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/workspaces/"); + + await vi.waitFor( + () => { + expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); + }, + { timeout: 8_000, interval: 16 }, ); - } finally { - await mounted.cleanup(); - } - }); - it("does not consume chat.new when there is no project context", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createProjectlessSnapshot(), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.new", - shortcut: { - key: "o", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/"); + await vi.waitFor( + () => { + expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); - try { - await waitForServerConfigToApply(); - dispatchChatNewShortcut(); - await waitForLayout(); + await dispatchInputKey(browseInput, { key: "Enter" }); - expect(mounted.router.state.location.pathname).toBe(serverThreadPath(THREAD_ID)); - expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadKey)).toHaveLength(0); + await vi.waitFor( + () => { + expect(remoteDispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "project.create", + workspaceRoot: "~/workspaces", + title: "workspaces", + }), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a remote project.", + ); } finally { await mounted.cleanup(); } }); - it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => { + it("picks a local project from the native file manager", async () => { + const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked"); + const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, - targetText: "command palette shortcut test", + targetMessageId: "msg-user-command-palette-add-project-file-manager" as MessageId, + targetText: "command palette add project file manager", }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "commandPalette.toggle", - shortcut: { - key: "k", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Applications/") { + return { + parentPath: "~/Applications/", + entries: [{ name: "Utilities", fullPath: "~/Applications/Utilities" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Applications", fullPath: "~/Applications" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; }, }); try { await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); + window.desktopBridge = { + pickFolder, + setTheme: vi.fn().mockResolvedValue(undefined), + } as unknown as NonNullable; + + await page.getByTestId("sidebar-add-project-trigger").click(); + const palette = page.getByTestId("command-palette"); await expect.element(palette).toBeInTheDocument(); - await expect - .element(palette.getByText("New thread in Project", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("New thread in Project", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); + const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await browseInput.fill("~/Applications/access"); + + const fileManagerLabel = isMacPlatform(navigator.platform) + ? "Open in Finder" + : navigator.platform.toLowerCase().startsWith("win") + ? "Open in Explorer" + : "Open in Files"; + await palette.getByRole("button", { name: fileManagerLabel }).click(); + + await vi.waitFor( + () => { + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "~/Applications" }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "/Users/julius/Projects/finder-picked", + title: "finder-picked", + }); + }, + { timeout: 8_000, interval: 16 }, + ); await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID from the command palette.", + "Route should have changed to a new draft thread after adding a project from the native file manager.", ); } finally { await mounted.cleanup(); } }); - it("filters command palette results as the user types", async () => { + it("adds a project from browse mode with Mod+Enter when a directory is highlighted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-search-test" as MessageId, - targetText: "command palette search test", + targetMessageId: "msg-user-command-palette-add-project-mod-enter" as MessageId, + targetText: "command palette add project mod enter", }), configureFixture: (nextFixture) => { nextFixture.serverConfig = { @@ -3742,6 +5235,32 @@ describe("ChatView timeline estimator parity (full app)", () => { ], }; }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [ + { name: "alpha", fullPath: "~/Development/alpha" }, + { name: "beta", fullPath: "~/Development/beta" }, + ], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, }); try { @@ -3751,11 +5270,65 @@ describe("ChatView timeline estimator parity (full app)", () => { await openCommandPaletteFromTrigger(); await expect.element(palette).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); - await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); + await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "ArrowDown" }); + + const addButtonLabel = isMacPlatform(navigator.platform) + ? "Add (\u2318 Enter)" + : "Add (Ctrl Enter)"; + await vi.waitFor( + () => { + const legendEntries = getCommandPaletteLegendEntries(); + expect(legendEntries).toContain("Enter Select"); + }, + { timeout: 8_000, interval: 16 }, + ); await expect - .element(palette.getByText("New thread in Project", { exact: true })) - .not.toBeInTheDocument(); + .element(palette.getByRole("button", { name: addButtonLabel })) + .toBeInTheDocument(); + + await dispatchInputKey(browseInput, { + key: "Enter", + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development", + title: "Development", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project with Mod+Enter.", + ); } finally { await mounted.cleanup(); } @@ -4270,7 +5843,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex-spark", + }, planMarkdown: "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", }), @@ -4300,7 +5876,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt({ - modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.3-codex-spark", + }, planMarkdown: "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", }), @@ -4373,6 +5952,204 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens the model picker when selecting /model", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-model-command-target" as MessageId, + targetText: "model command thread", + }), + }); + + try { + await waitForComposerEditor(); + await page.getByTestId("composer-editor").fill("/mod"); + + const menuItem = await waitForComposerMenuItem("slash:model"); + await menuItem.click(); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).not.toBeNull(); + expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); + }); + + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + + await vi.waitFor(() => { + const searchInput = document.querySelector( + 'input[placeholder="Search models..."]', + ); + expect(searchInput).not.toBeNull(); + expect(document.activeElement).toBe(searchInput); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, + targetText: "model picker shortcut thread", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID + ? Object.assign({}, project, { + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + }) + : project, + ), + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + }) + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "modelPicker.toggle", + shortcut: { + key: "m", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + modKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + { + command: "thread.jump.1", + shortcut: { + key: "1", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, + { + command: "modelPicker.jump.1", + shortcut: { + key: "1", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "modelPickerOpen" }, + }, + ], + providers: [ + { + ...nextFixture.serverConfig.providers[0]!, + models: [ + { + slug: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ], + }), + }, + ], + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); + + const initialPath = mounted.router.state.location.pathname; + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).not.toBeNull(); + }); + + const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; + await vi.waitFor(() => { + expect( + Array.from( + document.querySelectorAll('.model-picker-list [data-slot="kbd"]'), + ).some((element) => element.textContent?.trim() === jumpLabel), + ).toBe(true); + }); + expect(mounted.router.state.location.pathname).toBe(initialPath); + + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).toBeNull(); + }); + } finally { + releaseModShortcut("Control"); + await mounted.cleanup(); + } + }); + it("shows a tooltip with the skill description when hovering a skill pill", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 1b96265a40e..83c90edaddc 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,5 +1,12 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; -import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; @@ -11,6 +18,7 @@ import { deriveComposerSendState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; @@ -82,6 +90,17 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("resolveSendEnvMode", () => { + it("keeps worktree mode for git repositories", () => { + expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); + }); + + it("forces local mode for non-git repositories", () => { + expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); + expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local"); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps previously mounted open threads and adds the active open thread", () => { expect( @@ -208,7 +227,7 @@ const makeThread = (input?: { codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", - modelSelection: { provider: "codex" as const, model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access" as const, interactionMode: "default" as const, session: null, @@ -241,7 +260,7 @@ function setStoreThreads(threads: ReadonlyArray>) name: "Project", cwd: "/tmp/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, createdAt: "2026-03-29T00:00:00.000Z", @@ -440,7 +459,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { }; const previousSession = { - provider: "codex" as const, + provider: ProviderDriverKind.make("codex"), status: "ready" as const, createdAt: "2026-03-29T00:00:00.000Z", updatedAt: "2026-03-29T00:00:10.000Z", @@ -454,7 +473,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, @@ -491,7 +510,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, @@ -530,6 +549,142 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: previousLatestTurn, + session: { + ...previousSession, + status: "running", + orchestrationStatus: "running", + activeTurnId: TurnId.make("turn-2"), + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + + it("does not clear local dispatch while the session is running but latestTurn has not advanced yet", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: previousLatestTurn, + session: { + ...previousSession, + status: "running", + orchestrationStatus: "running", + activeTurnId: undefined, + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + + it("clears local dispatch once the running latestTurn matches the active session turn", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: { + ...previousLatestTurn, + turnId: TurnId.make("turn-2"), + state: "running", + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: null, + }, + session: { + ...previousSession, + status: "running", + orchestrationStatus: "running", + activeTurnId: TurnId.make("turn-2"), + updatedAt: "2026-03-29T00:01:01.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); + it("clears local dispatch when the session changes without an observed running phase", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.make("thread-1"), @@ -537,7 +692,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", session: previousSession, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a753a71b390..bf87add28d9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,26 +1,26 @@ import { type EnvironmentId, + isProviderDriverKind, ProjectId, type ModelSelection, - type ProviderKind, + type ProviderDriverKind, type ScopedThreadRef, type ThreadId, type TurnId, } from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; -import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { selectThreadByRef, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; +import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; -const WORKTREE_BRANCH_PREFIX = "t3code"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -157,10 +157,11 @@ export function readFileAsDataUrl(file: File): Promise { }); } -export function buildTemporaryWorktreeBranchName(): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. - const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; +export function resolveSendEnvMode(input: { + requestedEnvMode: DraftThreadEnvMode; + isGitRepo: boolean; +}): DraftThreadEnvMode { + return input.isGitRepo ? input.requestedEnvMode : "local"; } export function cloneComposerImageForRetry( @@ -226,15 +227,39 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } +// `threadProvider` is the open branded driver kind carried by the session. +// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe +// rollback / fork behavior — the routing layer is the right place to surface +// "driver not installed" errors, not the lock state. +// +// `selectedProvider` takes the same open-string shape because the composer +// now tracks the picker selection as a `ProviderInstanceId` (e.g. +// `codex_personal`). Custom instance ids that don't directly match a +// registered driver resolve to `null` here, which matches the existing +// "unknown driver -> unlocked" semantics. Callers that want the lock to track +// a custom instance's underlying driver kind should resolve the instance id +// upstream and pass the correlated kind. export function deriveLockedProvider(input: { thread: Thread | null | undefined; - selectedProvider: ProviderKind | null; - threadProvider: ProviderKind | null; -}): ProviderKind | null { + selectedProvider: string | null; + threadProvider: string | null; +}): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; } - return input.thread?.session?.provider ?? input.threadProvider ?? input.selectedProvider ?? null; + const sessionProvider = input.thread?.session?.provider ?? null; + if (sessionProvider) { + return sessionProvider; + } + const narrowedThreadProvider = + input.threadProvider && isProviderDriverKind(input.threadProvider) + ? input.threadProvider + : null; + const narrowedSelectedProvider = + input.selectedProvider && isProviderDriverKind(input.selectedProvider) + ? input.selectedProvider + : null; + return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } export async function waitForStartedServerThread( @@ -322,23 +347,37 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (!input.localDispatch) { return false; } - if ( - input.phase === "running" || - input.hasPendingApproval || - input.hasPendingUserInput || - Boolean(input.threadError) - ) { + if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; - - return ( + const latestTurnChanged = input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || - input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) || + input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); + + if (input.phase === "running") { + if (!latestTurnChanged) { + return false; + } + if (latestTurn?.startedAt === null || latestTurn === null) { + return false; + } + if ( + session?.activeTurnId !== undefined && + session.activeTurnId !== null && + latestTurn?.turnId !== session.activeTurnId + ) { + return false; + } + return true; + } + + return ( + latestTurnChanged || input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875ccc..d66d2487ce3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,21 +1,23 @@ import { type ApprovalRequestId, - DEFAULT_MODEL_BY_PROVIDER, - type ClaudeCodeEffort, + DEFAULT_MODEL, + defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, - type ProviderKind, type ProjectId, type ProviderApprovalDecision, + ProviderInstanceId, type ServerProvider, + type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type TurnId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, + ProviderDriverKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -25,10 +27,15 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + createModelSelection, + resolvePromptInjectedEffort, +} from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Debouncer } from "@tanstack/react-pacer"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -57,7 +64,7 @@ import { isLatestTurnSettled, formatElapsed, } from "../session-logic"; -import { isScrollContainerNearBottom } from "../chat-scroll"; +import { type LegendListRef } from "@legendapp/list/react"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -90,13 +97,16 @@ import { import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -107,10 +117,14 @@ import { import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; -import { resolveAppModelSelection } from "../modelSelection"; +import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { deriveLogicalProjectKey } from "../logicalProject"; import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { + reconnectSavedEnvironment, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; @@ -138,11 +152,11 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, - buildTemporaryWorktreeBranchName, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, @@ -155,6 +169,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, @@ -168,14 +183,28 @@ import { useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { RightPanelSheet } from "./RightPanelSheet"; +import { Button } from "./ui/button"; +import { + buildVersionMismatchDismissalKey, + dismissVersionMismatch, + isVersionMismatchDismissed, + resolveServerConfigVersionMismatch, +} from "../versionSkew"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; -const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; +const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +type EnvironmentUnavailableState = { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly connectionState: "connecting" | "disconnected" | "error"; +}; type ThreadPlanCatalogEntry = Pick; @@ -293,17 +322,15 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog } function formatOutgoingPrompt(params: { - provider: ProviderKind; + provider: ProviderDriverKind; model: string | null; models: ReadonlyArray; effort: string | null; text: string; }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); - } - return params.text; + const promptEffort = resolvePromptInjectedEffort(caps, params.effort); + return applyClaudePromptEffortPrefix(params.text, promptEffort); } const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -313,6 +340,7 @@ type ChatViewProps = environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; + reserveTitleBarControlInset?: boolean; routeKind: "server"; draftId?: never; } @@ -320,6 +348,7 @@ type ChatViewProps = environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; + reserveTitleBarControlInset?: boolean; routeKind: "draft"; draftId: DraftId; }; @@ -408,6 +437,7 @@ interface PersistentThreadTerminalDrawerProps { splitShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -420,6 +450,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra splitShortcutLabel, newShortcutLabel, closeShortcutLabel, + keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); @@ -563,6 +594,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra splitShortcutLabel={visible ? splitShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} closeShortcutLabel={visible ? closeShortcutLabel : undefined} + keybindings={keybindings} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={setTerminalHeight} @@ -573,7 +605,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }); export default function ChatView(props: ChatViewProps) { - const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; + const { + environmentId, + threadId, + routeKind, + onDiffPanelOpen, + reserveTitleBarControlInset = true, + } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -590,23 +628,15 @@ export default function ChatView(props: ChatViewProps) { ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const setThreadChangedFilesExpanded = useUiStateStore( - (store) => store.setThreadChangedFilesExpanded, - ); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); - const changedFilesExpandedByTurnId = useUiStateStore((store) => - routeKind === "server" - ? (store.threadChangedFilesExpandedById[routeThreadKey] ?? - EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID) - : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, - ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; + const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -673,14 +703,13 @@ export default function ChatView(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); - const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -690,32 +719,20 @@ export default function ChatView(props: ChatViewProps) { const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); + const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = + useState(null); + const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); - const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = useState(null); - const shouldAutoScrollRef = useRef(true); - const lastKnownScrollTopRef = useRef(0); - const isPointerScrollActiveRef = useRef(false); - const lastTouchClientYRef = useRef(null); - const pendingUserScrollUpIntentRef = useRef(false); - const pendingAutoScrollFrameRef = useRef(null); - const pendingInteractionAnchorRef = useRef<{ - element: HTMLElement; - top: number; - } | null>(null); - const pendingInteractionAnchorFrameRef = useRef(null); + const legendListRef = useRef(null); + const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), @@ -780,8 +797,8 @@ export default function ChatView(props: ChatViewProps) { threadId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, }, localDraftError, ) @@ -843,16 +860,98 @@ export default function ChatView(props: ChatViewProps) { useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); + useEffect(() => { + if (routeKind !== "server") { + return; + } + return retainThreadDetailSubscription(environmentId, threadId); + }, [environmentId, routeKind, threadId]); + // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const activeSavedEnvironmentRecord = + activeThread && activeThread.environmentId !== primaryEnvironmentId + ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null) + : null; + const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord + ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null) + : null; + const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord + ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected") + : "connected"; + const activeEnvironmentUnavailable = + activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected"; + const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null; + const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord + ? resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId: activeSavedEnvironmentRecord.environmentId, + runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null, + savedLabel: activeSavedEnvironmentRecord.label, + }) + : null; + const activeEnvironmentUnavailableState = useMemo(() => { + if ( + !activeEnvironmentUnavailable || + !activeEnvironmentUnavailableLabel || + !activeSavedEnvironmentId + ) { + return null; + } + + return { + environmentId: activeSavedEnvironmentId, + label: activeEnvironmentUnavailableLabel, + connectionState: + activeSavedEnvironmentConnectionState === "connecting" || + activeSavedEnvironmentConnectionState === "error" + ? activeSavedEnvironmentConnectionState + : "disconnected", + }; + }, [ + activeEnvironmentUnavailable, + activeEnvironmentUnavailableLabel, + activeSavedEnvironmentConnectionState, + activeSavedEnvironmentId, + ]); + const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState( + null, + ); + const handleReconnectActiveEnvironment = useCallback( + async (environmentId: EnvironmentId, label: string) => { + setReconnectingEnvironmentId(environmentId); + try { + await reconnectSavedEnvironment(environmentId); + toastManager.add({ + type: "success", + title: "Environment reconnected", + description: `${label} is ready.`, + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not reconnect environment", + description: error instanceof Error ? error.message : "Failed to reconnect.", + }), + ); + } finally { + setReconnectingEnvironmentId(null); + } + }, + [], + ); + const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKey(activeProject); - const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const memberProjects = allProjects.filter( + (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, + ); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; @@ -888,6 +987,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, allProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -917,7 +1017,10 @@ export default function ChatView(props: ChatViewProps) { throw new Error("No active project is available for this pull request."); } const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); if (storedDraftSession) { setDraftThreadContext(storedDraftSession.draftId, input); @@ -978,6 +1081,7 @@ export default function ChatView(props: ChatViewProps) { getDraftSessionByLogicalProjectKey, isServerThread, navigate, + projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, @@ -995,16 +1099,6 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - const handleSetChangedFilesExpanded = useCallback( - (turnId: TurnId, expanded: boolean) => { - if (routeKind !== "server") { - return; - } - setThreadChangedFilesExpanded(routeThreadKey, turnId, expanded); - }, - [routeKind, routeThreadKey, setThreadChangedFilesExpanded], - ); - useEffect(() => { if (!serverThread?.id) return; if (!latestTurnSettled) return; @@ -1014,7 +1108,10 @@ export default function ChatView(props: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + activeLatestTurn.completedAt, + ); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, @@ -1026,7 +1123,9 @@ export default function ChatView(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = - activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; + activeThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, @@ -1043,12 +1142,126 @@ export default function ChatView(props: ChatViewProps) { primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId ? primaryServerConfig : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); + const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); + const versionMismatchDismissKey = + versionMismatch && activeThread + ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) + : null; + const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( + null, + ); + const versionMismatchDismissed = + versionMismatchDismissKey === dismissedVersionMismatchKey || + isVersionMismatchDismissed(versionMismatchDismissKey); + const showVersionMismatchBanner = + versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; + const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; + const versionMismatchServerLabel = useMemo(() => { + if (!hasMultipleRegisteredEnvironments || !activeThread) { + return "server"; + } + + const isPrimary = activeThread.environmentId === primaryEnvironmentId; + const savedRecord = savedEnvironmentRegistry[activeThread.environmentId]; + const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId]; + return `${resolveEnvironmentOptionLabel({ + isPrimary, + environmentId: activeThread.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null, + savedLabel: savedRecord?.label ?? null, + })} server`; + }, [ + activeThread, + hasMultipleRegisteredEnvironments, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + serverConfig?.environment.label, + ]); + const composerBannerItems = useMemo(() => { + const items: ComposerBannerStackItem[] = []; + if (activeEnvironmentUnavailableState) { + items.push({ + id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, + variant: + activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", + icon: , + title: ( + <> + {activeEnvironmentUnavailableState.label} is{" "} + {activeEnvironmentUnavailableState.connectionState === "connecting" + ? "connecting" + : "disconnected"} + + ), + description: "Reconnect this environment before sending messages or running actions.", + actions: ( + <> + + + + ), + }); + } + if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { + items.push({ + id: `version-mismatch:${versionMismatchDismissKey}`, + variant: "warning", + icon: , + title: "Client and server versions differ", + description: ( + <> + Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} + {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. + + ), + dismissLabel: "Dismiss version mismatch warning", + onDismiss: () => { + dismissVersionMismatch(versionMismatchDismissKey); + setDismissedVersionMismatchKey(versionMismatchDismissKey); + }, + }); + } + return items; + }, [ + activeEnvironmentUnavailableState, + handleReconnectActiveEnvironment, + navigate, + reconnectingEnvironmentId, + showVersionMismatchBanner, + versionMismatch, + versionMismatchDismissKey, + versionMismatchServerLabel, + ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? "codex", + selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); - const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -1123,6 +1336,7 @@ export default function ChatView(props: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -1144,7 +1358,6 @@ export default function ChatView(props: ChatViewProps) { threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -1420,10 +1633,24 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, - [selectedProvider, providerStatuses], - ); + // Prefer an instance-id match so a custom Codex instance (e.g. + // `codex_personal`) surfaces its own status/message in the banner rather + // than the default Codex's. Falls back to first-match-by-kind when no + // saved instance id is available or the instance no longer exists. + const activeProviderInstanceId = + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null; + const activeProviderStatus = useMemo(() => { + if (activeProviderInstanceId) { + return ( + providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null + ); + } + const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); + return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; + }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -1852,11 +2079,13 @@ export default function ChatView(props: ChatViewProps) { title: `Deleted action "${deletedName ?? "Unknown"}"`, }); } catch (error) { - toastManager.add({ - type: "error", - title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not delete action", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); } }, [activeProject, persistProjectScripts], @@ -1905,16 +2134,19 @@ export default function ChatView(props: ChatViewProps) { const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; - if (turnKey) { - planSidebarDismissedForTurnRef.current = turnKey; - } + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; } else { planSidebarDismissedForTurnRef.current = null; } return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + const closePlanSidebar = useCallback(() => { + setPlanSidebarOpen(false); + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -1935,7 +2167,7 @@ export default function ChatView(props: ChatViewProps) { if ( input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || - input.modelSelection.provider !== serverThread.modelSelection.provider || + input.modelSelection.instanceId !== serverThread.modelSelection.instanceId || JSON.stringify(input.modelSelection.options ?? null) !== JSON.stringify(serverThread.modelSelection.options ?? null)) ) { @@ -1970,178 +2202,62 @@ export default function ChatView(props: ChatViewProps) { [environmentId, serverThread], ); - // Auto-scroll on new messages - const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, []); - const cancelPendingStickToBottom = useCallback(() => { - const pendingFrame = pendingAutoScrollFrameRef.current; - if (pendingFrame === null) return; - pendingAutoScrollFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); + // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. + const scrollToEnd = useCallback((animated = false) => { + legendListRef.current?.scrollToEnd?.({ animated }); }, []); - const cancelPendingInteractionAnchorAdjustment = useCallback(() => { - const pendingFrame = pendingInteractionAnchorFrameRef.current; - if (pendingFrame === null) return; - pendingInteractionAnchorFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const scheduleStickToBottom = useCallback(() => { - if (pendingAutoScrollFrameRef.current !== null) return; - pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null; - scrollMessagesToBottom(); - }); - }, [scrollMessagesToBottom]); - const onMessagesClickCapture = useCallback( - (event: React.MouseEvent) => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer || !(event.target instanceof Element)) return; - - const trigger = event.target.closest( - "button, summary, [role='button'], [data-scroll-anchor-target]", - ); - if (!trigger || !scrollContainer.contains(trigger)) return; - if (trigger.closest("[data-scroll-anchor-ignore]")) return; - - pendingInteractionAnchorRef.current = { - element: trigger, - top: trigger.getBoundingClientRect().top, - }; - cancelPendingInteractionAnchorAdjustment(); - pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { - pendingInteractionAnchorFrameRef.current = null; - const anchor = pendingInteractionAnchorRef.current; - pendingInteractionAnchorRef.current = null; - const activeScrollContainer = messagesScrollRef.current; - if (!anchor || !activeScrollContainer) return; - if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; - - const nextTop = anchor.element.getBoundingClientRect().top; - const delta = nextTop - anchor.top; - if (Math.abs(delta) < 0.5) return; - - activeScrollContainer.scrollTop += delta; - lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; - }); - }, - [cancelPendingInteractionAnchorAdjustment], - ); - const forceStickToBottom = useCallback(() => { - cancelPendingStickToBottom(); - scrollMessagesToBottom(); - scheduleStickToBottom(); - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); - const onMessagesScroll = useCallback(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - const currentScrollTop = scrollContainer.scrollTop; - const isNearBottom = isScrollContainerNearBottom(scrollContainer); - - if (!shouldAutoScrollRef.current && isNearBottom) { - shouldAutoScrollRef.current = true; - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { - shouldAutoScrollRef.current = false; - } - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp && !isNearBottom) { - shouldAutoScrollRef.current = false; - } - } else if (shouldAutoScrollRef.current && !isNearBottom) { - // Catch-all for keyboard/assistive scroll interactions. - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } - - setShowScrollToBottom(!shouldAutoScrollRef.current); - lastKnownScrollTopRef.current = currentScrollTop; - }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; + // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during + // thread switches. LegendList fires scroll events with isAtEnd=false while + // initialScrollAtEnd is settling; hiding is always immediate. + const showScrollDebouncer = useRef( + new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), + ); + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + if (isAtEndRef.current === isAtEnd) return; + isAtEndRef.current = isAtEnd; + if (isAtEnd) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + showScrollDebouncer.current.maybeExecute(); } - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; }, []); - useEffect(() => { - return () => { - cancelPendingStickToBottom(); - cancelPendingInteractionAnchorAdjustment(); - }; - }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); - useLayoutEffect(() => { - if (!activeThread?.id) return; - shouldAutoScrollRef.current = true; - scheduleStickToBottom(); - const timeout = window.setTimeout(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - if (isScrollContainerNearBottom(scrollContainer)) return; - scheduleStickToBottom(); - }, 96); - return () => { - window.clearTimeout(timeout); - }; - }, [activeThread?.id, scheduleStickToBottom]); - useEffect(() => { - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [messageCount, scheduleStickToBottom]); - useEffect(() => { - if (phase !== "running") return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [phase, scheduleStickToBottom, timelineEntries]); useEffect(() => { - setExpandedWorkGroups({}); setPullRequestDialogState(null); + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); } else { + planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(false); } planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); + // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. + // Don't auto-open for plans carried over from a previous turn (the user can open manually). + useEffect(() => { + if (!autoOpenPlanSidebar) return; + if (!activePlan) return; + if (planSidebarOpen) return; + const latestTurnId = activeLatestTurn?.turnId ?? null; + if (latestTurnId && activePlan.turnId !== latestTurnId) return; + const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + if (planSidebarDismissedForTurnRef.current === turnKey) return; + setPlanSidebarOpen(true); + }, [ + activePlan, + activeLatestTurn?.turnId, + autoOpenPlanSidebar, + planSidebarOpen, + sidebarProposedPlan?.turnId, + ]); + useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -2200,11 +2316,42 @@ export default function ChatView(props: ChatViewProps) { }, []); const activeWorktreePath = activeThread?.worktreePath ?? null; - const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ + const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ activeWorktreePath, hasServerThread: isServerThread, draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, }); + const canOverrideServerThreadEnvMode = Boolean( + isServerThread && + activeThread && + activeThread.messages.length === 0 && + activeThread.worktreePath === null && + !envLocked, + ); + const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode + ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode) + : derivedEnvMode; + const activeThreadBranch = + canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined + ? pendingServerThreadBranch + : (activeThread?.branch ?? null); + const sendEnvMode = resolveSendEnvMode({ + requestedEnvMode: envMode, + isGitRepo, + }); + + useEffect(() => { + setPendingServerThreadEnvMode(null); + setPendingServerThreadBranch(undefined); + }, [activeThread?.id]); + + useEffect(() => { + if (canOverrideServerThreadEnvMode) { + return; + } + setPendingServerThreadEnvMode(null); + setPendingServerThreadBranch(undefined); + }, [canOverrideServerThreadEnvMode]); useEffect(() => { if (!activeThreadId) { @@ -2290,16 +2437,6 @@ export default function ChatView(props: ChatViewProps) { terminalState.terminalOpen, ]); - useEffect(() => { - if (phase !== "running") return; - const timer = window.setInterval(() => { - setNowTick(Date.now()); - }, 1000); - return () => { - window.clearInterval(timer); - }; - }, [phase]); - useEffect(() => { if (!activeThreadKey) return; const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; @@ -2330,6 +2467,7 @@ export default function ChatView(props: ChatViewProps) { const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; const command = resolveShortcutCommand(event, keybindings, { @@ -2379,6 +2517,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "modelPicker.toggle") { + event.preventDefault(); + event.stopPropagation(); + composerRef.current?.toggleModelPicker(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2387,8 +2532,8 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); void runProjectScript(script); }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); }, [ activeProject, terminalState.terminalOpen, @@ -2410,6 +2555,13 @@ export default function ChatView(props: ChatViewProps) { const localApi = readLocalApi(); if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; + if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { + setThreadError( + activeThread.id, + `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + ); + return; + } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; @@ -2445,6 +2597,8 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThread, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableLabel, environmentId, isConnecting, isRevertingCheckpoint, @@ -2457,7 +2611,15 @@ export default function ChatView(props: ChatViewProps) { const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if ( + !api || + !activeThread || + isSendBusy || + isConnecting || + activeEnvironmentUnavailable || + sendInFlightRef.current + ) + return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -2515,11 +2677,13 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "empty", ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); } return; } @@ -2527,15 +2691,15 @@ export default function ChatView(props: ChatViewProps) { const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath - ? activeThread.branch + isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath + ? activeThreadBranch : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThread.branch) { + isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; + if (shouldCreateWorktree && !activeThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -2575,6 +2739,14 @@ export default function ChatView(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); + // Scroll to the current end *before* adding the optimistic message. + // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd + // automatically pins to the new item when the data changes. + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + await legendListRef.current?.scrollToEnd?.({ animated: false }); + setOptimisticUserMessages((existing) => [ ...existing, { @@ -2586,9 +2758,6 @@ export default function ChatView(props: ChatViewProps) { streaming: false, }, ]); - // Sending a message should always bring the latest user turn into view. - shouldAutoScrollRef.current = true; - forceStickToBottom(); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { @@ -2596,11 +2765,13 @@ export default function ChatView(props: ChatViewProps) { expiredTerminalContextCount, "omitted", ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -2626,16 +2797,11 @@ export default function ChatView(props: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: ctxSelectedProvider, - model: - ctxSelectedModel || - activeProject.defaultModelSelection?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, - ...(ctxSelectedModelSelection.options - ? { options: ctxSelectedModelSelection.options } - : {}), - }; + const threadCreateModelSelection = createModelSelection( + ctxSelectedModelSelection.instanceId, + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, + ctxSelectedModelSelection.options, + ); // Auto-title from first message if (isFirstMessage && isServerThread) { @@ -2669,7 +2835,7 @@ export default function ChatView(props: ChatViewProps) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThread.branch, + branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, @@ -2969,6 +3135,13 @@ export default function ChatView(props: ChatViewProps) { sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); + + // Scroll to the current end *before* adding the optimistic message. + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + await legendListRef.current?.scrollToEnd?.({ animated: false }); + setOptimisticUserMessages((existing) => [ ...existing, { @@ -2979,8 +3152,6 @@ export default function ChatView(props: ChatViewProps) { streaming: false, }, ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); try { await persistThreadSettingsForNextTurn({ @@ -3025,7 +3196,7 @@ export default function ChatView(props: ChatViewProps) { // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { + if (nextInteractionMode === "default" && autoOpenPlanSidebar) { planSidebarDismissedForTurnRef.current = null; setPlanSidebarOpen(true); } @@ -3046,7 +3217,6 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeProposedPlan, beginLocalDispatch, - forceStickToBottom, isConnecting, isSendBusy, isServerThread, @@ -3055,6 +3225,7 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, setComposerDraftInteractionMode, setThreadError, + autoOpenPlanSidebar, environmentId, ], ); @@ -3069,6 +3240,7 @@ export default function ChatView(props: ChatViewProps) { !isServerThread || isSendBusy || isConnecting || + activeEnvironmentUnavailable || sendInFlightRef.current ) { return; @@ -3117,7 +3289,7 @@ export default function ChatView(props: ChatViewProps) { modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", - branch: activeThread.branch, + branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt, }) @@ -3147,8 +3319,8 @@ export default function ChatView(props: ChatViewProps) { return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { - // Signal that the plan sidebar should open on the new thread. - planSidebarOpenOnNextThreadRef.current = true; + // Signal that the plan sidebar should open on the new thread when enabled. + planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; return navigate({ to: "/$environmentId/$threadId", params: { @@ -3165,44 +3337,76 @@ export default function ChatView(props: ChatViewProps) { threadId: nextThreadId, }) .catch(() => undefined); - toastManager.add({ - type: "error", - title: "Could not start implementation thread", - description: - err instanceof Error ? err.message : "An error occurred while creating the new thread.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start implementation thread", + description: + err instanceof Error + ? err.message + : "An error occurred while creating the new thread.", + }), + ); }) .then(finish, finish); }, [ activeProject, activeProposedPlan, + activeThreadBranch, activeThread, beginLocalDispatch, + activeEnvironmentUnavailable, isConnecting, isSendBusy, isServerThread, navigate, resetLocalDispatch, runtimeMode, + autoOpenPlanSidebar, environmentId, ]); const onProviderModelSelect = useCallback( - (provider: ProviderKind, model: string) => { + (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; - if (lockedProvider !== null && provider !== lockedProvider) { + // Look up the configured instance so model normalization and custom + // model lookup stay scoped to that exact instance. Unknown instance ids + // are rejected by returning early; the server remains authoritative too. + const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); + const resolvedDriverKind = entry?.driver ?? null; + if ( + lockedProvider !== null && + resolvedDriverKind !== null && + resolvedDriverKind !== lockedProvider + ) { scheduleComposerFocus(); return; } - const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); - const resolvedModel = resolveAppModelSelection( - resolvedProvider, + if (lockedProvider !== null && activeThread.session?.providerInstanceId) { + const currentEntry = providerStatuses.find( + (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, + ); + if ( + currentEntry?.continuation?.groupKey && + entry?.continuation?.groupKey && + currentEntry.continuation.groupKey !== entry.continuation.groupKey + ) { + scheduleComposerFocus(); + return; + } + } + const resolvedModel = resolveAppModelSelectionForInstance( + instanceId, settings, providerStatuses, model, ); + if (!resolvedModel) { + scheduleComposerFocus(); + return; + } const nextModelSelection: ModelSelection = { - provider: resolvedProvider, + instanceId, model: resolvedModel, }; setComposerDraftModelSelection( @@ -3224,6 +3428,11 @@ export default function ChatView(props: ChatViewProps) { ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { + if (canOverrideServerThreadEnvMode) { + setPendingServerThreadEnvMode(mode); + scheduleComposerFocus(); + return; + } if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, @@ -3233,20 +3442,16 @@ export default function ChatView(props: ChatViewProps) { scheduleComposerFocus(); }, [ + canOverrideServerThreadEnvMode, composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, + setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, ], ); - const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((existing) => ({ - ...existing, - [groupId]: !existing[groupId], - })); - }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -3272,16 +3477,19 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); - const onRevertUserMessage = useCallback( - (messageId: MessageId) => { - const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCount(targetTurnCount); - }, - [onRevertToTurnCount, revertTurnCountByUserMessageId], - ); + // Both the Map and the revert handler are read from refs at call-time so + // the callback reference is fully stable and never busts context identity. + const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); + revertTurnCountRef.current = revertTurnCountByUserMessageId; + const onRevertToTurnCountRef = useRef(onRevertToTurnCount); + onRevertToTurnCountRef.current = onRevertToTurnCount; + const onRevertUserMessage = useCallback((messageId: MessageId) => { + const targetTurnCount = revertTurnCountRef.current.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + void onRevertToTurnCountRef.current(targetTurnCount); + }, []); // Empty state: no active thread if (!activeThread) { @@ -3293,8 +3501,14 @@ export default function ChatView(props: ChatViewProps) { {/* Top bar */}
    {/* Messages Wrapper */}
    - {/* Messages */} -
    - 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnId={activeLatestTurn?.turnId ?? null} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - activeThreadEnvironmentId={activeThread.environmentId} - expandedWorkGroups={expandedWorkGroups} - onToggleWorkGroup={onToggleWorkGroup} - changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} - onSetChangedFilesExpanded={handleSetChangedFilesExpanded} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={activeWorkspaceRoot} - /> -
    + {/* Messages — LegendList handles virtualization and scrolling internally */} + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && (
    {/* Input bar */} -
    - +
    +
    + +
    + +
    +
    + {isGitRepo && ( + + )}
    - {isGitRepo && ( - - )} {pullRequestDialogState ? ( { - setPlanSidebarOpen(false); - // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; - if (turnKey) { - planSidebarDismissedForTurnRef.current = turnKey; - } - }} + mode="sidebar" + onClose={closePlanSidebar} /> ) : null}
    @@ -3540,9 +3752,25 @@ export default function ChatView(props: ChatViewProps) { splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} + keybindings={keybindings} onAddTerminalContext={addTerminalContextToDraft} /> ))} + {shouldUsePlanSidebarSheet ? ( + + + + ) : null} {expandedImage && ( diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index a49dadc851f..38b44f3f6a7 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { buildThreadActionItems, @@ -17,7 +17,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: PROJECT_ID, title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", session: null, diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index c29f1271087..3f4997e215c 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,4 @@ -import { type KeybindingCommand } from "@t3tools/contracts"; +import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; @@ -17,6 +17,11 @@ export interface CommandPaletteItem { readonly description?: string; readonly timestamp?: string; readonly icon: ReactNode; + readonly disabled?: boolean; + /** Optional content rendered inline before the title text. */ + readonly titleLeadingContent?: ReactNode; + /** Optional content rendered inline after the title text (before the timestamp). */ + readonly titleTrailingContent?: ReactNode; readonly shortcutCommand?: KeybindingCommand; } @@ -45,7 +50,39 @@ export interface CommandPaletteView { readonly initialQuery?: string; } -export type CommandPaletteMode = "root" | "submenu"; +export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; + +export function filterBrowseEntries(input: { + browseEntries: ReadonlyArray; + browseFilterQuery: string; + highlightedItemValue: string | null; +}): { + filteredEntries: FilesystemBrowseEntry[]; + highlightedEntry: FilesystemBrowseEntry | null; + exactEntry: FilesystemBrowseEntry | null; +} { + const lowerFilter = input.browseFilterQuery.toLowerCase(); + const showHidden = input.browseFilterQuery.startsWith("."); + + const filteredEntries = input.browseEntries.filter( + (entry) => + entry.name.toLowerCase().startsWith(lowerFilter) && + (showHidden || !entry.name.startsWith(".")), + ); + + let highlightedEntry: FilesystemBrowseEntry | null = null; + if (input.highlightedItemValue?.startsWith("browse:")) { + const highlightedPath = input.highlightedItemValue.slice("browse:".length); + highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null; + } + + const exactEntry = + input.browseFilterQuery.length > 0 + ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) + : null; + + return { filteredEntries, highlightedEntry, exactEntry }; +} export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); @@ -70,20 +107,24 @@ export function buildProjectActionItems(input: { })); } -export function buildThreadActionItems(input: { - threads: ReadonlyArray< - Pick< - SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" - > & { - updatedAt?: string | undefined; - latestUserMessageAt?: string | null; - } - >; +export type BuildThreadActionItemsThread = Pick< + SidebarThreadSummary, + "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" +> & { + updatedAt?: string | undefined; + latestUserMessageAt?: string | null; +}; + +export function buildThreadActionItems(input: { + threads: ReadonlyArray; activeThreadId?: Thread["id"]; projectTitleById: ReadonlyMap; sortOrder: SidebarThreadSortOrder; icon: ReactNode; + /** Optional content rendered inline before the title text per-thread. */ + renderLeadingContent?: (thread: TThread) => ReactNode; + /** Optional content rendered inline after the title text per-thread. */ + renderTrailingContent?: (thread: TThread) => ReactNode; runThread: (thread: Pick) => Promise; limit?: number; }): CommandPaletteActionItem[] { @@ -108,18 +149,29 @@ export function buildThreadActionItems(input: { descriptionParts.push("Current thread"); } - return { - kind: "action", - value: `thread:${thread.id}`, - searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], - title: thread.title, - description: descriptionParts.join(" · "), - timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt), - icon: input.icon, - run: async () => { - await input.runThread(thread); + const leadingContent = input.renderLeadingContent?.(thread); + const trailingContent = input.renderTrailingContent?.(thread); + + return Object.assign( + { + kind: "action" as const, + value: `thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``], + title: thread.title, + description: descriptionParts.join(` · `), + timestamp: formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ), + icon: input.icon, + }, + leadingContent ? { titleLeadingContent: leadingContent } : {}, + trailingContent ? { titleTrailingContent: trailingContent } : {}, + { + run: async () => { + await input.runThread(thread); + }, }, - }; + ); }); } @@ -228,10 +280,56 @@ export function filterCommandPaletteGroups(input: { }); } +export function buildBrowseGroups(input: { + browseEntries: ReadonlyArray; + browseQuery: string; + canBrowseUp: boolean; + upIcon: ReactNode; + directoryIcon: ReactNode; + browseUp: () => void; + browseTo: (name: string) => void; +}): CommandPaletteGroup[] { + const items: CommandPaletteActionItem[] = []; + + if (input.canBrowseUp) { + items.push({ + kind: "action", + value: "browse:up", + searchTerms: [input.browseQuery, ".."], + title: "..", + icon: input.upIcon, + keepOpen: true, + run: async () => { + input.browseUp(); + }, + }); + } + + for (const entry of input.browseEntries) { + items.push({ + kind: "action", + value: `browse:${entry.fullPath}`, + searchTerms: [input.browseQuery, entry.fullPath, entry.name], + title: entry.name, + icon: input.directoryIcon, + keepOpen: true, + run: async () => { + input.browseTo(entry.name); + }, + }); + } + + return [{ value: "directories", label: "Directories", items }]; +} + export function getCommandPaletteMode(input: { currentView: CommandPaletteView | null; + isBrowsing: boolean; }): CommandPaletteMode { - return input.currentView ? "submenu" : "root"; + if (input.currentView) { + return input.isBrowsing ? "submenu-browse" : "submenu"; + } + return input.isBrowsing ? "root-browse" : "root"; } export function buildRootGroups(input: { @@ -256,7 +354,11 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str switch (mode) { case "root": return "Search commands, projects, and threads..."; + case "root-browse": + return "Enter project path (e.g. ~/projects/my-app)"; case "submenu": return "Search..."; + case "submenu-browse": + return "Enter path (e.g. ~/projects/my-app)"; } } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4028043f196..7862191d40b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,19 +1,36 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { ProjectId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + type EnvironmentId, + type FilesystemBrowseResult, + type ProjectId, + ProviderInstanceId, + type SourceControlDiscoveryResult, + type SourceControlProviderKind, + type SourceControlRepositoryInfo, +} from "@t3tools/contracts"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; +import * as Option from "effect/Option"; import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, + CornerLeftUpIcon, + FolderIcon, + FolderPlusIcon, + LinkIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; import { + useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -22,15 +39,41 @@ import { } from "react"; import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { readEnvironmentApi } from "../environmentApi"; +import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; +import { readLocalApi } from "../localApi"; +import { + getSourceControlDiscoverySnapshot, + refreshSourceControlDiscovery, +} from "../lib/sourceControlDiscoveryState"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, + findProjectByPath, + getBrowseDirectoryPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + inferProjectTitleFromPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, + isUnsupportedWindowsProjectPath, + resolveProjectPathForDispatch, +} from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; -import { cn } from "../lib/utils"; +import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; import { selectProjectsAcrossEnvironments, selectSidebarThreadsAcrossEnvironments, @@ -40,20 +83,25 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { ADDON_ICON_CLASS, + buildBrowseGroups, buildProjectActionItems, buildRootGroups, buildThreadActionItems, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, + filterBrowseEntries, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; +import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; import { resolveShortcutCommand } from "../keybindings"; import { @@ -64,11 +112,220 @@ import { CommandInput, CommandPanel, } from "./ui/command"; +import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const BROWSE_STALE_TIME_MS = 30_000; + +function getLocalFileManagerName(platform: string): string { + if (isMacPlatform(platform)) { + return "Finder"; + } + if (isWindowsPlatform(platform)) { + return "Explorer"; + } + return "Files"; +} + +function getEnvironmentBrowsePlatform(os: string | null | undefined): string { + if (os === "windows") { + return "Win32"; + } + if (os === "darwin") { + return "MacIntel"; + } + if (os === "linux") { + return "Linux"; + } + return typeof navigator === "undefined" ? "" : navigator.platform; +} + +interface AddProjectEnvironmentOption { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; +} + +type AddProjectRemoteProviderKind = Extract< + SourceControlProviderKind, + "github" | "gitlab" | "bitbucket" | "azure-devops" +>; +type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; + +type AddProjectCloneFlow = + | { + readonly step: "repository"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + } + | { + readonly step: "confirm"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + readonly repositoryInput: string; + readonly repository: SourceControlRepositoryInfo | null; + readonly remoteUrl: string; + }; + +const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ + "url", + "github", + "gitlab", + "bitbucket", + "azure-devops", +]; +const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ + "github", + "gitlab", + "bitbucket", + "azure-devops", +]; + +function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { + switch (source) { + case "github": + return "GitHub"; + case "gitlab": + return "GitLab"; + case "bitbucket": + return "Bitbucket"; + case "azure-devops": + return "Azure DevOps"; + case "url": + return "Git URL"; + } +} + +function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { + switch (source) { + case "github": + return "owner/repo"; + case "gitlab": + return "group/project"; + case "bitbucket": + return "workspace/repository"; + case "azure-devops": + return "project/repository"; + case "url": + return "URL"; + } +} + +function remoteProjectSourceProvider( + source: AddProjectRemoteSource, +): AddProjectRemoteProviderKind | null { + return source === "url" ? null : source; +} + +function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { + switch (source) { + case "github": + return ; + case "gitlab": + return ; + case "bitbucket": + return ; + case "azure-devops": + return ; + case "url": + return ; + } +} + +function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { + if (!flow) return null; + if (flow.step === "confirm") return null; + if (flow.source === "url") { + return "Enter Git clone URL"; + } + return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; +} + +function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { + return source === "url" ? null : source; +} + +function sortAddProjectProviderSources( + readinessBySource: AddProjectRemoteSourceReadiness, +): ReadonlyArray { + return REMOTE_PROJECT_PROVIDER_SOURCES.toSorted((left, right) => { + const leftReady = readinessBySource[left].ready; + const rightReady = readinessBySource[right].ready; + if (leftReady !== rightReady) { + return leftReady ? -1 : 1; + } + return remoteProjectSourceLabel(left).localeCompare(remoteProjectSourceLabel(right)); + }); +} + +type AddProjectRemoteSourceReadiness = Record< + AddProjectRemoteSource, + { readonly ready: boolean; readonly hint: string | null } +>; + +function buildAddProjectRemoteSourceReadiness( + discovery: SourceControlDiscoveryResult | null, +): AddProjectRemoteSourceReadiness { + const unavailable = { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + } as const; + const defaultReadiness: AddProjectRemoteSourceReadiness = { + url: { ready: true, hint: null }, + github: unavailable, + gitlab: unavailable, + bitbucket: unavailable, + "azure-devops": unavailable, + }; + + if (!discovery) { + return defaultReadiness; + } + + const providerByKind = new Map( + discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), + ); + const readiness = { ...defaultReadiness }; + + for (const source of REMOTE_PROJECT_SOURCES) { + const kind = sourceProviderKind(source); + if (!kind) continue; + const provider = providerByKind.get(kind); + if (!provider) { + readiness[source] = unavailable; + continue; + } + if (provider.status !== "available") { + readiness[source] = { ready: false, hint: provider.installHint }; + continue; + } + if (provider.auth.status === "unauthenticated") { + readiness[source] = { + ready: false, + hint: + Option.getOrNull(provider.auth.detail) ?? + `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + continue; + } + readiness[source] = { ready: true, hint: null }; + } + + return readiness; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return "An error occurred."; +} + export function CommandPalette({ children }: { children: ReactNode }) { const open = useCommandPaletteStore((store) => store.open); const setOpen = useCommandPaletteStore((store) => store.setOpen); @@ -107,12 +364,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { }, [keybindings, terminalOpen, toggleOpen]); return ( - + {children} - + ); } @@ -136,10 +393,14 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); const setOpen = useCommandPaletteStore((store) => store.setOpen); + const openIntent = useCommandPaletteStore((store) => store.openIntent); + const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); + const queryClient = useQueryClient(); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); const settings = useSettings(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); @@ -148,15 +409,205 @@ function OpenCommandPaletteDialog() { const keybindings = useServerKeybindings(); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; - const paletteMode = getCommandPaletteMode({ currentView }); + const [browseGeneration, setBrowseGeneration] = useState(0); + const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( + null, + ); + const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); + const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); + const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + + const addProjectEnvironmentOptions = useMemo(() => { + const options: AddProjectEnvironmentOption[] = []; + const seenEnvironmentIds = new Set(); + + if (primaryEnvironmentId) { + seenEnvironmentIds.add(primaryEnvironmentId); + options.push({ + environmentId: primaryEnvironmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: primaryEnvironmentId, + runtimeLabel: primaryEnvironmentLabel, + }), + isPrimary: true, + }); + } + + for (const record of Object.values(savedEnvironmentRegistry)) { + if (seenEnvironmentIds.has(record.environmentId)) { + continue; + } + + const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; + options.push({ + environmentId: record.environmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId: record.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? null, + savedLabel: record.label, + }), + isPrimary: false, + }); + } + options.sort((left, right) => { + if (left.isPrimary !== right.isPrimary) { + return left.isPrimary ? -1 : 1; + } + return left.label.localeCompare(right.label); + }); + + return options; + }, [ + primaryEnvironmentId, + primaryEnvironmentLabel, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; + const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; + const browseEnvironmentPlatform = useMemo(() => { + const os = + browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId + ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) + : browseEnvironmentId + ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? + savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform + .os ?? + null) + : null; + return getEnvironmentBrowsePlatform(os); + }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); + const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; + const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; + const isBrowsing = + !isRemoteProjectRepositoryStep && isFilesystemBrowseQuery(query, browseEnvironmentPlatform); + const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); + const getAddProjectInitialQueryForEnvironment = useCallback( + (environmentId: EnvironmentId | null): string => { + const environmentSettings = + environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId + ? settings + : environmentId + ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings + : null; + const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; + if (baseDirectory.length === 0) { + return "~/"; + } + return ensureBrowseDirectoryPath(baseDirectory); + }, + [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + ); + + const projectCwdById = useMemo( + () => new Map(projects.map((project) => [project.id, project.cwd])), + [projects], + ); const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.name])), [projects], ); const activeThreadId = activeThread?.id; + const currentProjectEnvironmentId = + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + const currentProjectCwd = currentProjectId + ? (projectCwdById.get(currentProjectId) ?? null) + : null; + const currentProjectCwdForBrowse = + browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId + ? currentProjectCwd + : null; + const relativePathNeedsActiveProject = + isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; + const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; + const browseFilterQuery = + isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; + + const fetchBrowseResult = useCallback( + async (partialPath: string): Promise => { + if (!browseEnvironmentId) return null; + const api = readEnvironmentApi(browseEnvironmentId); + if (!api) return null; + return api.filesystem.browse({ + partialPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse], + ); + + const { data: browseResult, isPending: isBrowsePending } = useQuery({ + queryKey: [ + "filesystemBrowse", + browseEnvironmentId, + browseDirectoryPath, + currentProjectCwdForBrowse, + ], + queryFn: () => fetchBrowseResult(browseDirectoryPath), + staleTime: BROWSE_STALE_TIME_MS, + enabled: + isBrowsing && + browseDirectoryPath.length > 0 && + browseEnvironmentId !== null && + !relativePathNeedsActiveProject, + }); + const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; + const { + filteredEntries: filteredBrowseEntries, + highlightedEntry: highlightedBrowseEntry, + exactEntry: exactBrowseEntry, + } = useMemo( + () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), + [browseEntries, browseFilterQuery, highlightedItemValue], + ); + + const prefetchBrowsePath = useCallback( + (partialPath: string) => { + void queryClient.prefetchQuery({ + queryKey: [ + "filesystemBrowse", + browseEnvironmentId, + partialPath, + currentProjectCwdForBrowse, + ], + queryFn: () => fetchBrowseResult(partialPath), + staleTime: BROWSE_STALE_TIME_MS, + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], + ); + + // Prefetch the parent and the most likely next child so browse navigation + // stays warm without scanning every child directory in large trees. + useEffect(() => { + if (!isBrowsing || filteredBrowseEntries.length === 0) return; + + if (canNavigateUp(query)) { + prefetchBrowsePath(getBrowseParentPath(query)!); + } + + const nextChild = highlightedBrowseEntry ?? exactBrowseEntry; + if (nextChild) { + prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name)); + } + }, [ + exactBrowseEntry, + filteredBrowseEntries.length, + highlightedBrowseEntry, + isBrowsing, + prefetchBrowsePath, + query, + ]); const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { @@ -248,6 +699,8 @@ function OpenCommandPaletteDialog() { projectTitleById, sortOrder: settings.sidebarThreadSortOrder, icon: , + renderLeadingContent: (thread) => , + renderTrailingContent: (thread) => , runThread: async (thread) => { await navigate({ to: "/$environmentId/$threadId", @@ -259,30 +712,273 @@ function OpenCommandPaletteDialog() { ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); - function pushView(item: CommandPaletteSubmenuItem): void { + function pushPaletteView(view: CommandPaletteView): void { setViewStack((previousViews) => [ ...previousViews, { - addonIcon: item.addonIcon, - groups: item.groups, - ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), }, ]); - setQuery(item.initialQuery ?? ""); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + } + + function pushView(item: CommandPaletteSubmenuItem): void { + pushPaletteView({ + addonIcon: item.addonIcon, + groups: item.groups, + ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + }); } function popView(): void { + setAddProjectCloneFlow(null); + if (viewStack.length <= 1) { + setAddProjectEnvironmentId(null); + } setViewStack((previousViews) => previousViews.slice(0, -1)); + setHighlightedItemValue(null); setQuery(""); } function handleQueryChange(nextQuery: string): void { + setHighlightedItemValue(null); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); } } + const startAddProjectBrowse = useCallback( + (environmentId: EnvironmentId): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + pushPaletteView({ + addonIcon: , + groups: [], + initialQuery: getAddProjectInitialQueryForEnvironment(environmentId), + }); + }, + [getAddProjectInitialQueryForEnvironment], + ); + + const startAddProjectClone = useCallback( + (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow({ step: "repository", environmentId, source }); + pushPaletteView({ + addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), + groups: [], + initialQuery: "", + }); + }, + [], + ); + + const openSourceControlSettings = useCallback(() => { + setOpen(false); + void navigate({ to: "/settings/source-control" }); + }, [navigate, setOpen]); + + const buildAddProjectSourceGroups = useCallback( + ( + environmentId: EnvironmentId, + readinessBySource: AddProjectRemoteSourceReadiness, + ): CommandPaletteView["groups"] => { + const sourceItems: Array = [ + { + kind: "action", + value: `action:add-project:${environmentId}:local`, + searchTerms: ["local", "folder", "directory", "browse"], + title: "Local folder", + description: "Browse a folder on disk", + icon: , + keepOpen: true, + run: async () => { + startAddProjectBrowse(environmentId); + }, + }, + ]; + + const orderedSources: ReadonlyArray = [ + "url", + ...sortAddProjectProviderSources(readinessBySource), + ]; + + for (const source of orderedSources) { + const label = remoteProjectSourceLabel(source); + const title = source === "url" ? "Git URL" : `${label} repository`; + const description = + source === "url" + ? "Clone from a remote URL" + : `Clone ${label} ${remoteProjectSourcePathHint(source)}`; + const readiness = readinessBySource[source]; + const disabledHint = readiness.hint; + + const titleTrailingContent = readiness.ready ? undefined : ( + + + { + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {disabledHint ?? "Open Settings -> Source Control to configure this provider."} + + + + ); + + if (!readiness.ready) { + sourceItems.push({ + kind: "action", + value: `action:add-project:${environmentId}:${source}:not-ready`, + searchTerms: ["clone", "remote", "repository", "repo", "git", label, "setup required"], + title, + description, + disabled: true, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + ...(titleTrailingContent ? { titleTrailingContent } : {}), + run: async () => {}, + }); + continue; + } + + sourceItems.push({ + kind: "action", + value: `action:add-project:${environmentId}:${source}`, + searchTerms: ["clone", "remote", "repository", "repo", "git", label], + title, + description, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + ...(titleTrailingContent ? { titleTrailingContent } : {}), + keepOpen: true, + run: async () => { + startAddProjectClone(environmentId, source); + }, + }); + } + + return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; + }, + [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], + ); + + const startAddProjectSourceSelection = useCallback( + (environmentId: EnvironmentId): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + const target = { environmentId }; + const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; + pushPaletteView({ + addonIcon: , + groups: buildAddProjectSourceGroups( + environmentId, + buildAddProjectRemoteSourceReadiness(initialDiscovery), + ), + }); + + if (initialDiscovery) { + return; + } + + void refreshSourceControlDiscovery(target).then((discovery) => { + setViewStack((previousViews) => { + const currentTopView = previousViews.at(-1); + if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { + return previousViews; + } + return [ + ...previousViews.slice(0, -1), + { + addonIcon: , + groups: buildAddProjectSourceGroups( + environmentId, + buildAddProjectRemoteSourceReadiness(discovery), + ), + }, + ]; + }); + }); + }, + [buildAddProjectSourceGroups], + ); + + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( + (option) => ({ + kind: "action", + value: `action:add-project:environment:${option.environmentId}`, + searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], + title: option.label, + description: option.isPrimary ? "This device" : option.environmentId, + icon: , + keepOpen: true, + run: async () => { + startAddProjectSourceSelection(option.environmentId); + }, + }), + ); + + const addProjectEnvironmentGroups = useMemo( + () => [ + { + value: "environments", + label: "Environments", + items: addProjectEnvironmentItems, + }, + ], + [addProjectEnvironmentItems], + ); + + const openAddProjectFlow = useCallback(() => { + if (addProjectEnvironmentOptions.length > 1) { + pushPaletteView({ + addonIcon: , + groups: addProjectEnvironmentGroups, + }); + return; + } + + const environmentId = defaultAddProjectEnvironmentId; + if (!environmentId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to browse projects", + description: "No environment is available.", + }), + ); + return; + } + + void startAddProjectSourceSelection(environmentId); + }, [ + addProjectEnvironmentGroups, + addProjectEnvironmentOptions.length, + defaultAddProjectEnvironmentId, + startAddProjectSourceSelection, + ]); + + useLayoutEffect(() => { + if (openIntent?.kind !== "add-project") { + return; + } + clearOpenIntent(); + openAddProjectFlow(); + }, [clearOpenIntent, openAddProjectFlow, openIntent]); + const actionItems: Array = []; if (projects.length > 0) { @@ -325,6 +1021,35 @@ function OpenCommandPaletteDialog() { }); } + actionItems.push({ + kind: "action", + value: "action:add-project", + searchTerms: [ + "add project", + "folder", + "directory", + "browse", + "clone", + "remote", + "repository", + "repo", + "git", + "github", + "gitlab", + "bitbucket", + "azure", + "devops", + "url", + "environment", + ], + title: "Add project", + icon: , + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", @@ -339,7 +1064,7 @@ function OpenCommandPaletteDialog() { const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const activeGroups = currentView ? currentView.groups : rootGroups; - const displayedGroups = filterCommandPaletteGroups({ + const filteredGroups = filterCommandPaletteGroups({ activeGroups, query: deferredQuery, isInSubmenu: currentView !== null, @@ -347,10 +1072,395 @@ function OpenCommandPaletteDialog() { threadSearchItems: allThreadItems, }); - const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); - const isSubmenu = paletteMode === "submenu"; + const handleAddProject = useCallback( + async (rawCwd: string) => { + if (!browseEnvironmentId) return; + const api = readEnvironmentApi(browseEnvironmentId); + if (!api) return; + + if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: "Windows-style paths are only supported on Windows.", + }), + ); + return; + } + + if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: "Relative paths require an active project.", + }), + ); + return; + } + + const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse); + if (cwd.length === 0) return; + + const existing = findProjectByPath( + projects.filter((project) => project.environmentId === browseEnvironmentId), + cwd, + ); + if (existing) { + const latestThread = getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === existing.environmentId), + existing.id, + settings.sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(latestThread.environmentId, latestThread.id), + ), + }); + } else { + await handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { + envMode: settings.defaultThreadEnvMode, + }).catch(() => undefined); + } + setOpen(false); + return; + } + + try { + const projectId = newProjectId(); + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: inferProjectTitleFromPath(cwd), + workspaceRoot: cwd, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, + createdAt: new Date().toISOString(), + }); + await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { + envMode: settings.defaultThreadEnvMode, + }).catch(() => undefined); + setOpen(false); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [ + browseEnvironmentId, + browseEnvironmentPlatform, + currentProjectCwdForBrowse, + handleNewThread, + navigate, + projects, + setOpen, + settings.defaultThreadEnvMode, + settings.sidebarThreadSortOrder, + threads, + ], + ); + + function getDefaultCloneParentPath(environmentId: EnvironmentId): string { + return getAddProjectInitialQueryForEnvironment(environmentId); + } + + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { + if (!addProjectCloneFlow) { + return; + } + + const api = readEnvironmentApi(addProjectCloneFlow.environmentId); + if (!api) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to clone project", + description: "Environment API is not available.", + }), + ); + return; + } + + if (addProjectCloneFlow.step === "repository") { + const rawRepository = query.trim(); + if (rawRepository.length === 0 || isRemoteProjectLookingUp) { + return; + } + + const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); + if (!provider) { + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository: null, + remoteUrl: rawRepository, + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); + return; + } + + setIsRemoteProjectLookingUp(true); + try { + const repository = await api.sourceControl.lookupRepository({ + provider, + repository: rawRepository, + }); + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository, + remoteUrl: repository.sshUrl, + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Repository lookup failed", + description: errorMessage(error), + }), + ); + } finally { + setIsRemoteProjectLookingUp(false); + } + return; + } + + const rawDestination = (destinationPathInput ?? query).trim(); + if (rawDestination.length === 0 || isRemoteProjectCloning) { + return; + } + + if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: "Windows-style paths are only supported on Windows.", + }), + ); + return; + } + + if (isExplicitRelativeProjectPath(rawDestination) && !currentProjectCwdForBrowse) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: "Relative paths require an active project.", + }), + ); + return; + } + + const destinationPath = resolveProjectPathForDispatch( + rawDestination, + currentProjectCwdForBrowse, + ); + if (destinationPath.length === 0) { + return; + } + + setIsRemoteProjectCloning(true); + try { + const result = await api.sourceControl.cloneRepository({ + remoteUrl: addProjectCloneFlow.remoteUrl, + destinationPath, + }); + await handleAddProject(result.cwd); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: errorMessage(error), + }), + ); + } finally { + setIsRemoteProjectCloning(false); + } + } + + function browseTo(name: string): void { + const nextQuery = appendBrowsePathSegment(query, name); + setHighlightedItemValue(null); + setQuery(nextQuery); + setBrowseGeneration((generation) => generation + 1); + } + + function browseUp(): void { + const parentPath = getBrowseParentPath(query); + if (parentPath === null) { + return; + } + + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); + } + + // Resolve the add-project path from browse data when available. When the + // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the + // directory itself. Otherwise the user typed a partial leaf name, so we need + // the exact browse entry's fullPath or fall back to the raw query. + const resolvedAddProjectPath = hasTrailingPathSeparator(query) + ? (browseResult?.parentPath ?? query.trim()) + : (exactBrowseEntry?.fullPath ?? query.trim()); + + const canBrowseUp = + isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath); + + const browseGroups = buildBrowseGroups({ + browseEntries: filteredBrowseEntries, + browseQuery: query, + canBrowseUp, + upIcon: , + directoryIcon: , + browseUp, + browseTo, + }); + const cloneDestinationBrowseGroups = useMemo( + () => + browseGroups.map((group) => + group.value === "directories" ? { ...group, label: "Select where to clone" } : group, + ), + [browseGroups], + ); + + const remoteProjectContext = useMemo(() => { + if (addProjectCloneFlow?.step !== "confirm") { + return null; + } + + return { + title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput, + description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl, + icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS), + }; + }, [addProjectCloneFlow]); + + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; + if (addProjectCloneFlow?.step === "repository") { + displayedGroups = []; + } else if (addProjectCloneFlow?.step === "confirm") { + displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; + } else if (isBrowsing) { + displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; + } + + const inputPlaceholder = + remoteProjectInputPlaceholder(addProjectCloneFlow) ?? + getCommandPaletteInputPlaceholder(paletteMode); + const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; + const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; + const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; + const willCreateProjectPath = + canSubmitBrowsePath && + !isBrowsePending && + query.trim().length > 0 && + !hasHighlightedBrowseItem && + (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); + const useMetaForMod = isMacPlatform(navigator.platform); + const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; + const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; + const submitActionLabel = isCloneDestinationStep + ? willCreateProjectPath + ? "Create & Clone" + : "Clone" + : willCreateProjectPath + ? "Create & Add" + : "Add"; + const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"; + const remoteProjectButtonLabel = addProjectCloneFlow + ? addProjectCloneFlow.source === "url" + ? "Continue" + : "Lookup" + : null; + const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning; + const canSubmitRemoteProjectFlow = + addProjectCloneFlow?.step === "repository" && + query.trim().length > 0 && + !isRemoteProjectPending; + const fileManagerName = getLocalFileManagerName(navigator.platform); + const canOpenProjectFromFileManager = + isBrowsing && + browseEnvironmentId !== null && + primaryEnvironmentId !== null && + browseEnvironmentId === primaryEnvironmentId && + typeof window !== "undefined" && + window.desktopBridge !== undefined; + const fileManagerInitialPath = useMemo(() => { + if (!canOpenProjectFromFileManager) { + return undefined; + } + + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) { + return undefined; + } + + const initialPath = hasTrailingPathSeparator(query) + ? (browseResult?.parentPath ?? trimmedQuery) + : browseDirectoryPath || trimmedQuery; + + const resolvedPath = resolveProjectPathForDispatch(initialPath, currentProjectCwdForBrowse); + return resolvedPath.length > 0 ? resolvedPath : undefined; + }, [ + browseDirectoryPath, + browseResult?.parentPath, + canOpenProjectFromFileManager, + currentProjectCwdForBrowse, + query, + ]); + + function isPrimaryModifierPressed(event: KeyboardEvent): boolean { + return useMetaForMod ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + } function handleKeyDown(event: KeyboardEvent): void { + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { + event.preventDefault(); + void submitAddProjectCloneFlow(); + return; + } + + const shouldSubmitBrowsePath = + canSubmitBrowsePath && + event.key === "Enter" && + (!hasHighlightedBrowseItem || isPrimaryModifierPressed(event)); + + if (shouldSubmitBrowsePath) { + event.preventDefault(); + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } + return; + } + if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); popView(); @@ -358,6 +1468,10 @@ function OpenCommandPaletteDialog() { } function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { + if (item.disabled) { + return; + } + if (item.kind === "submenu") { pushView(item); return; @@ -368,14 +1482,48 @@ function OpenCommandPaletteDialog() { } void item.run().catch((error: unknown) => { - toastManager.add({ - type: "error", - title: "Unable to run command", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to run command", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); } + const handleOpenProjectFromFileManager = useCallback(async () => { + if (!canOpenProjectFromFileManager || isPickingProjectFolder) { + return; + } + const api = readLocalApi(); + if (!api) { + return; + } + + setIsPickingProjectFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder( + fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : undefined, + ); + } catch { + // Ignore picker failures and leave the palette open. + setIsPickingProjectFolder(false); + return; + } + setIsPickingProjectFolder(false); + if (!pickedPath) { + return; + } + await handleAddProject(pickedPath); + }, [ + canOpenProjectFromFileManager, + fileManagerInitialPath, + handleAddProject, + isPickingProjectFolder, + ]); + return ( { + setOpen(false); + }} > { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} onValueChange={handleQueryChange} value={query} > - - - - ), +
    + + + + ), + } + : isBrowsing && !isSubmenu + ? { + startAddon: , + } + : {})} + onKeyDown={handleKeyDown} + /> + {addProjectCloneFlow?.step === "repository" ? ( + + ) : isBrowsing ? ( + + ) : null} +
    + {remoteProjectContext ? ( +
    +
    + Repository +
    +
    + {remoteProjectContext.icon} + + + {remoteProjectContext.title} + + + {remoteProjectContext.description} + + +
    +
    + ) : null}
    @@ -436,10 +1696,19 @@ function OpenCommandPaletteDialog() { Navigate - - Enter - Select - + {addProjectCloneFlow?.step === "repository" ? ( + + Enter + + {remoteProjectButtonLabel ?? "Continue"} + + + ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + + Enter + Select + + ) : null} {isSubmenu ? ( Backspace @@ -451,6 +1720,19 @@ function OpenCommandPaletteDialog() { Close
    + {canOpenProjectFromFileManager ? ( + + ) : null} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 72700471bac..4ad08db0824 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -14,10 +14,12 @@ import { CommandList, CommandShortcut, } from "./ui/command"; +import { cn } from "~/lib/utils"; interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; + highlightedItemValue?: string | null; isActionsOnly: boolean; keybindings: ResolvedKeybindingsConfig; onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; @@ -41,14 +43,19 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {group.label} - {(item) => ( - - )} + {(item) => + item.disabled ? ( + + ) : ( + + ) + } ))} @@ -56,8 +63,36 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +function DisabledCommandPaletteResultRow(props: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; +}) { + return ( +
    + {props.item.icon} + {props.item.description ? ( + + + {props.item.titleLeadingContent} + {props.item.title} + + + {props.item.description} + + + ) : ( + + {props.item.titleLeadingContent} + {props.item.title} + + )} + {props.item.titleTrailingContent} +
    + ); +} + function CommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + isActive: boolean; keybindings: ResolvedKeybindingsConfig; onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; }) { @@ -68,7 +103,10 @@ function CommandPaletteResultRow(props: { return ( { event.preventDefault(); }} @@ -79,16 +117,21 @@ function CommandPaletteResultRow(props: { {props.item.icon} {props.item.description ? ( - {props.item.title} + + {props.item.titleLeadingContent} + {props.item.title} + {props.item.description} ) : ( - + + {props.item.titleLeadingContent} {props.item.title} )} + {props.item.titleTrailingContent} {props.item.timestamp ? ( {props.item.timestamp} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 95157d787f3..6f17c866306 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -32,26 +32,20 @@ import { type ElementNode, type LexicalNode, type SerializedLexicalNode, - TextNode, - type EditorConfig, type EditorState, type NodeKey, - type SerializedTextNode, type Spread, } from "lexical"; import { createContext, - forwardRef, + use, useCallback, - useContext, useEffect, + useEffectEvent, useImperativeHandle, useLayoutEffect, useMemo, useRef, - type ClipboardEventHandler, - type ReactElement, - type Ref, } from "react"; import { @@ -75,6 +69,7 @@ import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; @@ -103,7 +98,7 @@ type SerializedComposerMentionNode = Spread< type: "composer-mention"; version: 1; }, - SerializedTextNode + SerializedLexicalNode >; type SerializedComposerSkillNode = Spread< @@ -132,7 +127,37 @@ const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: () => {}, }); -class ComposerMentionNode extends TextNode { +function ComposerMentionDecorator(props: { path: string }) { + const theme = resolvedThemeFromDocument(); + const chip = ( + + + {basenameOfPath(props.path)} + + ); + + return ( + + + + {props.path} + + + ); +} + +class ComposerMentionNode extends DecoratorNode { __path: string; static override getType(): string { @@ -144,12 +169,12 @@ class ComposerMentionNode extends TextNode { } static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path); + return $createComposerMentionNode(serializedNode.path).updateFromJSON(serializedNode); } constructor(path: string, key?: NodeKey) { + super(key); const normalizedPath = path.startsWith("@") ? path.slice(1) : path; - super(`@${normalizedPath}`, key); this.__path = normalizedPath; } @@ -162,41 +187,26 @@ class ComposerMentionNode extends TextNode { }; } - override createDOM(_config: EditorConfig): HTMLElement { + override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; - dom.contentEditable = "false"; - dom.setAttribute("spellcheck", "false"); - renderMentionChipDom(dom, this.__path); + dom.className = "inline-flex align-middle leading-none"; return dom; } - override updateDOM( - prevNode: ComposerMentionNode, - dom: HTMLElement, - _config: EditorConfig, - ): boolean { - dom.contentEditable = "false"; - if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { - renderMentionChipDom(dom, this.__path); - } - return false; - } - - override canInsertTextBefore(): false { + override updateDOM(): false { return false; } - override canInsertTextAfter(): false { - return false; + override getTextContent(): string { + return `@${this.__path}`; } - override isTextEntity(): true { + override isInline(): true { return true; } - override isToken(): true { - return true; + override decorate(): React.ReactElement { + return ; } } @@ -204,8 +214,6 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path)); } -const SKILL_CHIP_ICON_SVG = ``; - function resolveSkillDescription( skill: Pick, ): string | null { @@ -260,14 +268,14 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s return ( - + {props.skillDescription} ); } -class ComposerSkillNode extends DecoratorNode { +class ComposerSkillNode extends DecoratorNode { __skillName: string; __skillLabel: string; __skillDescription: string | null; @@ -335,7 +343,7 @@ class ComposerSkillNode extends DecoratorNode { return true; } - override decorate(): ReactElement { + override decorate(): React.ReactElement { return ( ; } -class ComposerTerminalContextNode extends DecoratorNode { +class ComposerTerminalContextNode extends DecoratorNode { __context: TerminalContextDraft; static override getType(): string { @@ -406,7 +414,7 @@ class ComposerTerminalContextNode extends DecoratorNode { return true; } - override decorate(): ReactElement { + override decorate(): React.ReactElement { return ; } } @@ -434,26 +442,6 @@ function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } -function renderMentionChipDom(container: HTMLElement, pathValue: string): void { - container.textContent = ""; - container.style.setProperty("user-select", "none"); - container.style.setProperty("-webkit-user-select", "none"); - - const theme = resolvedThemeFromDocument(); - const icon = document.createElement("img"); - icon.alt = ""; - icon.ariaHidden = "true"; - icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; - icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); - - const label = document.createElement("span"); - label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; - label.textContent = basenameOfPath(pathValue); - - container.append(icon, label); -} - function terminalContextSignature(contexts: ReadonlyArray): string { return contexts .map((context) => @@ -595,12 +583,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -642,12 +627,9 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe } if ($isTextNode(node)) { - if (node instanceof ComposerMentionNode) { - return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); - } return offset + Math.min(pointOffset, node.getTextContentSize()); } - if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -673,10 +655,7 @@ function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { - if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) { - return findSelectionPointForInlineToken(node, remainingRef); - } - if (node instanceof ComposerTerminalContextNode) { + if (isComposerInlineTokenNode(node)) { return findSelectionPointForInlineToken(node, remainingRef); } @@ -918,11 +897,8 @@ interface ComposerPromptEditorProps { key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; - onPaste: ClipboardEventHandler; -} - -interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps { - editorRef: Ref; + onPaste: React.ClipboardEventHandler; + editorRef: React.RefObject; } function ComposerCommandKeyPlugin(props: { @@ -1077,7 +1053,7 @@ function ComposerInlineTokenSelectionNormalizePlugin() { function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); - const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); + const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); useEffect(() => { return editor.registerCommand( @@ -1168,68 +1144,65 @@ function ComposerSurroundSelectionPlugin(props: { skillMetadataRef.current = skillMetadataByName(props.skills); }, [props.skills]); - const applySurroundInsertion = useCallback( - (inputData: string): boolean => { - const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); - const pendingSurroundSelection = pendingSurroundSelectionRef.current; - if (!surroundCloseSymbol) { - pendingSurroundSelectionRef.current = null; - return false; - } + const applySurroundInsertion = useEffectEvent((inputData: string): boolean => { + const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); + const pendingSurroundSelection = pendingSurroundSelectionRef.current; + if (!surroundCloseSymbol) { + pendingSurroundSelectionRef.current = null; + return false; + } - let handled = false; - editor.update(() => { - const selectionSnapshot = - pendingSurroundSelection ?? - (() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return null; - } - if ($selectionTouchesInlineToken(selection)) { - return null; - } - const range = getSelectionRangeForExpandedComposerOffsets(selection); - if (!range || range.start === range.end) { - return null; - } - const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { - return null; - } - return { - value, - expandedStart: range.start, - expandedEnd: range.end, - }; - })(); - - if (!selectionSnapshot || !surroundCloseSymbol) { - return; - } + let handled = false; + editor.update(() => { + const selectionSnapshot = + pendingSurroundSelection ?? + (() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return null; + } + if ($selectionTouchesInlineToken(selection)) { + return null; + } + const range = getSelectionRangeForExpandedComposerOffsets(selection); + if (!range || range.start === range.end) { + return null; + } + const value = $getRoot().getTextContent(); + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + return null; + } + return { + value, + expandedStart: range.start, + expandedEnd: range.end, + }; + })(); + + if (!selectionSnapshot || !surroundCloseSymbol) { + return; + } - const selectedText = selectionSnapshot.value.slice( - selectionSnapshot.expandedStart, - selectionSnapshot.expandedEnd, - ); - const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); - const selectionStart = collapseExpandedComposerCursor( - nextValue, - selectionSnapshot.expandedStart, - ); - $setSelectionRangeAtComposerOffsets( - selectionStart + inputData.length, - selectionStart + inputData.length + selectedText.length, - ); - handled = true; - pendingSurroundSelectionRef.current = null; - }); + const selectedText = selectionSnapshot.value.slice( + selectionSnapshot.expandedStart, + selectionSnapshot.expandedEnd, + ); + const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; + $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); + const selectionStart = collapseExpandedComposerCursor( + nextValue, + selectionSnapshot.expandedStart, + ); + $setSelectionRangeAtComposerOffsets( + selectionStart + inputData.length, + selectionStart + inputData.length + selectedText.length, + ); + handled = true; + pendingSurroundSelectionRef.current = null; + }); - return handled; - }, - [editor], - ); + return handled; + }); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -1406,7 +1379,7 @@ function ComposerSurroundSelectionPlugin(props: { } unregisterRootListener(); }; - }, [applySurroundInsertion, editor]); + }, [editor]); return null; } @@ -1424,7 +1397,7 @@ function ComposerPromptEditorInner({ onCommandKeyDown, onPaste, editorRef, -}: ComposerPromptEditorInnerProps) { +}: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); @@ -1507,7 +1480,7 @@ function ComposerPromptEditorInner({ const rootElement = editor.getRootElement(); if (!rootElement) return; const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); - rootElement.focus(); + rootElement.focus({ preventScroll: true }); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); @@ -1632,13 +1605,13 @@ function ComposerPromptEditorInner({ }, []); return ( - +
    0 ? null : ( -
    +
    {placeholder}
    ) @@ -1664,29 +1637,24 @@ function ComposerPromptEditorInner({
    - + ); } -export const ComposerPromptEditor = forwardRef< - ComposerPromptEditorHandle, - ComposerPromptEditorProps ->(function ComposerPromptEditor( - { - value, - cursor, - terminalContexts, - skills, - disabled, - placeholder, - className, - onRemoveTerminalContext, - onChange, - onCommandKeyDown, - onPaste, - }, - ref, -) { +export function ComposerPromptEditor({ + value, + cursor, + terminalContexts, + skills, + disabled, + placeholder, + className, + onRemoveTerminalContext, + onChange, + onCommandKeyDown, + onPaste, + editorRef, +}: ComposerPromptEditorProps) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); @@ -1721,10 +1689,10 @@ export const ComposerPromptEditor = forwardRef< onRemoveTerminalContext={onRemoveTerminalContext} onChange={onChange} onPaste={onPaste} - editorRef={ref} + editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(className ? { className } : {})} /> ); -}); +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 63a5231f73b..f178a69fb43 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -5,9 +5,11 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { scopeThreadRef } from "@t3tools/client-runtime"; import type { TurnId } from "@t3tools/contracts"; import { + ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, Columns2Icon, + PilcrowIcon, Rows3Icon, TextWrapIcon, } from "lucide-react"; @@ -160,6 +162,21 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; } +function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string { + switch (fileDiff.type) { + case "new": + return "text-[var(--diffs-addition-base)]"; + case "deleted": + return "text-[var(--diffs-deletion-base)]"; + case "change": + case "rename-pure": + case "rename-changed": + return "text-[var(--diffs-modified-base)]"; + default: + return "text-muted-foreground/80"; + } +} + interface DiffPanelProps { mode?: DiffPanelMode; } @@ -172,6 +189,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const [collapsedDiffFileKeys, setCollapsedDiffFileKeys] = useState>( + () => new Set(), + ); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); @@ -277,6 +298,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, + ignoreWhitespace: diffIgnoreWhitespace, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, enabled: isGitRepo, }), @@ -314,12 +336,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ); }, [renderablePatch]); + useEffect(() => { + if (renderableFiles.length === 0) { + setCollapsedDiffFileKeys((current) => (current.size === 0 ? current : new Set())); + return; + } + + const visibleFileKeys = new Set(renderableFiles.map(buildFileDiffRenderKey)); + setCollapsedDiffFileKeys((current) => { + const next = new Set([...current].filter((fileKey) => visibleFileKeys.has(fileKey))); + return next.size === current.size ? current : next; + }); + }, [renderableFiles]); + useEffect(() => { if (diffOpen && !previousDiffOpenRef.current) { setDiffWordWrap(settings.diffWordWrap); + setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); } previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffWordWrap]); + }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { @@ -342,6 +378,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [activeCwd], ); + const toggleDiffFileCollapsed = useCallback((fileKey: string) => { + setCollapsedDiffFileKeys((current) => { + const next = new Set(current); + if (next.has(fileKey)) { + next.delete(fileKey); + } else { + next.add(fileKey); + } + return next; + }); + }, []); const selectTurn = (turnId: TurnId) => { if (!activeThread) return; @@ -429,12 +476,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const headerRow = ( <>
    - {canScrollTurnStripLeft && ( -
    - )} - {canScrollTurnStripRight && ( -
    - )}
    ); @@ -603,11 +663,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const filePath = resolveFileDiffPath(fileDiff); const fileKey = buildFileDiffRenderKey(fileDiff); const themedFileKey = `${fileKey}:${resolvedTheme}`; + const collapsed = collapsedDiffFileKeys.has(fileKey); return (
    { const nativeEvent = event.nativeEvent as MouseEvent; const composedPath = nativeEvent.composedPath?.() ?? []; @@ -621,7 +682,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > ( + + )} options={{ + collapsed, diffStyle: diffRenderMode === "split" ? "split" : "unified", lineDiffType: "none", overflow: diffWordWrap ? "wrap" : "scroll", diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index c08c53325d7..829ed4159d4 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -11,7 +11,9 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { const shouldUseDragRegion = isElectron && mode !== "sheet"; return cn( "flex items-center justify-between gap-2 px-4", - shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", + shouldUseDragRegion + ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" + : "h-12 wco:max-h-[env(titlebar-area-height)]", ); } diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 651b383fa08..a2a801d54af 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -90,6 +90,7 @@ vi.mock("~/components/ui/toast", () => ({ promise: toastPromiseSpy, update: toastUpdateSpy, }, + stackedThreadToast: vi.fn((options: unknown) => options), })); vi.mock("~/editorPreferences", () => ({ @@ -99,12 +100,14 @@ vi.mock("~/editorPreferences", () => ({ vi.mock("~/lib/gitReactQuery", () => ({ gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), gitMutationKeys: { + publishRepository: vi.fn(() => ["publish-repository"]), pull: vi.fn(() => ["pull"]), runStackedAction: vi.fn(() => ["run-stacked-action"]), }, gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), invalidateGitQueries: invalidateGitQueriesSpy, + sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })), })); vi.mock("~/lib/gitStatusState", () => ({ @@ -112,7 +115,15 @@ vi.mock("~/lib/gitStatusState", () => ({ resetGitStatusStateForTests: () => undefined, useGitStatus: vi.fn(() => ({ data: { - branch: BRANCH_NAME, + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: BRANCH_NAME, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 267cbec180d..7950753330e 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusResult } from "@t3tools/contracts"; +import type { VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vitest"; import { buildGitActionProgressStages, @@ -11,12 +11,12 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -function status(overrides: Partial = {}): GitStatusResult { +function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/test", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/test", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -31,7 +31,7 @@ function status(overrides: Partial = {}): GitStatusResult { }; } -describe("when: branch is clean and has an open PR", () => { +describe("when: ref is clean and has an open PR", () => { it("resolveQuickAction opens the existing PR", () => { const quick = resolveQuickAction( status({ @@ -39,8 +39,8 @@ describe("when: branch is clean and has an open PR", () => { number: 10, title: "Open PR", url: "https://example.com/pr/10", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -56,8 +56,8 @@ describe("when: branch is clean and has an open PR", () => { number: 11, title: "Existing PR", url: "https://example.com/pr/11", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -150,7 +150,7 @@ describe("when: git status is unavailable", () => { }); }); -describe("when: branch is clean, ahead, and has an open PR", () => { +describe("when: ref is clean, ahead, and has an open PR", () => { it("resolveQuickAction prefers push", () => { const quick = resolveQuickAction( status({ @@ -159,8 +159,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { number: 13, title: "Open PR", url: "https://example.com/pr/13", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -177,8 +177,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { number: 12, title: "Existing PR", url: "https://example.com/pr/12", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -212,7 +212,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { }); }); -describe("when: branch is clean, ahead, and has no open PR", () => { +describe("when: ref is clean, ahead, and has no open PR", () => { it("resolveQuickAction pushes and creates a PR", () => { const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { @@ -253,7 +253,53 @@ describe("when: branch is clean, ahead, and has no open PR", () => { }); }); -describe("when: branch is clean, up to date, and has no open PR", () => { +describe("when: source control provider uses merge requests", () => { + it("uses GitLab MR terminology in quick actions and menu items", () => { + const gitlabStatus = status({ + aheadCount: 2, + sourceControlProvider: { + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", + }, + }); + + const quick = resolveQuickAction(gitlabStatus, false); + const items = buildMenuItems(gitlabStatus, false); + + assert.deepInclude(quick, { + kind: "run_action", + action: "create_pr", + label: "Push & create MR", + }); + assert.deepInclude(items[2], { + id: "pr", + label: "Create MR", + }); + }); +}); + +describe("when: ref is clean, up to date, and has no open PR", () => { + it("enables create PR when synced with upstream but ahead of default", () => { + const syncedFeature = status({ + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 1, + pr: null, + }); + + const quick = resolveQuickAction(syncedFeature, false); + assert.deepInclude(quick, { + label: "Create PR", + disabled: false, + kind: "run_action", + action: "create_pr", + }); + + const items = buildMenuItems(syncedFeature, false); + assert.equal(items.find((item) => item.id === "pr")?.disabled, false); + }); + it("resolveQuickAction returns disabled no-action state", () => { const quick = resolveQuickAction( status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), @@ -293,7 +339,7 @@ describe("when: branch is clean, up to date, and has no open PR", () => { }); }); -describe("when: branch is behind upstream", () => { +describe("when: ref is behind upstream", () => { it("resolveQuickAction returns pull", () => { const quick = resolveQuickAction(status({ behindCount: 2 }), false); assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); @@ -330,11 +376,11 @@ describe("when: branch is behind upstream", () => { }); }); -describe("when: branch has diverged from upstream", () => { +describe("when: ref has diverged from upstream", () => { it("resolveQuickAction returns a disabled sync hint", () => { const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); assert.deepEqual(quick, { - label: "Sync branch", + label: "Sync ref", disabled: true, kind: "show_hint", hint: "Branch has diverged from upstream. Rebase/merge first.", @@ -375,8 +421,8 @@ describe("when: working tree has local changes", () => { number: 16, title: "Existing PR", url: "https://example.com/pr/16", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -418,12 +464,54 @@ describe("when: working tree has local changes", () => { }, ]); }); + + it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { + const items = buildMenuItems( + status({ + refName: "feature/test", + hasWorkingTreeChanges: true, + aheadCount: 1, + workingTree: { + files: [{ path: ".vercel/project.json", insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + }), + false, + ); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: false, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: false, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); }); -describe("when: on default branch without open PR", () => { +describe("when: on default ref without open PR", () => { it("resolveQuickAction returns commit and push when local changes exist", () => { const quick = resolveQuickAction( - status({ branch: "main", hasWorkingTreeChanges: true }), + status({ refName: "main", hasWorkingTreeChanges: true }), false, true, ); @@ -435,9 +523,9 @@ describe("when: on default branch without open PR", () => { }); }); - it("resolveQuickAction returns push when branch is ahead", () => { + it("resolveQuickAction returns push when ref is ahead", () => { const quick = resolveQuickAction( - status({ branch: "main", aheadCount: 2, pr: null }), + status({ refName: "main", aheadCount: 2, pr: null }), false, true, ); @@ -450,7 +538,7 @@ describe("when: on default branch without open PR", () => { }); }); -describe("when: working tree has local changes and branch is behind upstream", () => { +describe("when: working tree has local changes and ref is behind upstream", () => { it("resolveQuickAction still prefers commit, push, and create PR", () => { const quick = resolveQuickAction( status({ hasWorkingTreeChanges: true, behindCount: 1 }), @@ -497,14 +585,14 @@ describe("when: working tree has local changes and branch is behind upstream", ( describe("when: HEAD is detached and there are no local changes", () => { it("resolveQuickAction shows detached head hint", () => { const quick = resolveQuickAction( - status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), + status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }), false, ); assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); it("buildMenuItems keeps commit, push, and PR disabled", () => { - const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false); + const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false); assert.deepEqual(items, [ { id: "commit", @@ -534,7 +622,7 @@ describe("when: HEAD is detached and there are no local changes", () => { }); }); -describe("when: branch has no upstream configured", () => { +describe("when: ref has no upstream configured", () => { it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { const quick = resolveQuickAction( status({ hasUpstream: false, pr: null, aheadCount: 0 }), @@ -557,8 +645,8 @@ describe("when: branch has no upstream configured", () => { number: 14, title: "Existing PR", url: "https://example.com/pr/14", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -580,8 +668,8 @@ describe("when: branch has no upstream configured", () => { number: 15, title: "Existing PR", url: "https://example.com/pr/15", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -642,7 +730,7 @@ describe("when: branch has no upstream configured", () => { }); }); - it("resolveQuickAction disables push-and-pr flows when no origin remote exists", () => { + it("resolveQuickAction publishes when no origin remote exists", () => { const quick = resolveQuickAction( status({ hasUpstream: false, @@ -654,10 +742,9 @@ describe("when: branch has no upstream configured", () => { false, ); assert.deepEqual(quick, { - kind: "show_hint", - label: "Push", - hint: 'Add an "origin" remote before pushing or creating a PR.', - disabled: true, + kind: "open_publish", + label: "Publish repository", + disabled: false, }); }); @@ -691,7 +778,7 @@ describe("when: branch has no upstream configured", () => { ]); }); - it("buildMenuItems disables push and create PR when no origin remote exists", () => { + it("buildMenuItems hides push and create PR when no origin remote exists", () => { const items = buildMenuItems( status({ hasUpstream: false, pr: null, aheadCount: 2 }), false, @@ -706,29 +793,13 @@ describe("when: branch has no upstream configured", () => { kind: "open_dialog", dialogAction: "commit", }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, ]); }); - it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => { + it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => { const quick = resolveQuickAction( status({ - branch: "main", + refName: "main", hasUpstream: false, aheadCount: 0, pr: null, @@ -744,10 +815,10 @@ describe("when: branch has no upstream configured", () => { }); }); - it("resolveQuickAction uses push-only on default branch when no upstream exists and commits are ahead", () => { + it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => { const quick = resolveQuickAction( status({ - branch: "main", + refName: "main", hasUpstream: false, aheadCount: 1, pr: null, @@ -763,7 +834,7 @@ describe("when: branch has no upstream configured", () => { }); }); - it("buildMenuItems still disables push and create PR when branch is behind", () => { + it("buildMenuItems still disables push and create PR when ref is behind", () => { const items = buildMenuItems( status({ hasUpstream: false, @@ -803,7 +874,7 @@ describe("when: branch has no upstream configured", () => { }); describe("requiresDefaultBranchConfirmation", () => { - it("requires confirmation for push actions on default branch", () => { + it("requires confirmation for push actions on default ref", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); assert.isTrue(requiresDefaultBranchConfirmation("push", true)); assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); @@ -823,9 +894,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push to default branch?", + title: "Push to default ref?", description: - 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.', + 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', continueLabel: "Push to main", }); }); @@ -838,9 +909,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push & create PR from default branch?", + title: "Push & create PR from default ref?", description: - 'This action will push local commits and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', + 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', continueLabel: "Push & create PR", }); }); @@ -853,9 +924,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Commit, push & create PR from default branch?", + title: "Commit, push & create PR from default ref?", description: - 'This action will commit, push, and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', + 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', continueLabel: "Commit, push & create PR", }); }); @@ -884,7 +955,7 @@ describe("buildGitActionProgressStages", () => { "Pushing to origin/feature/test...", "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); @@ -898,7 +969,7 @@ describe("buildGitActionProgressStages", () => { assert.deepEqual(stages, [ "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); @@ -928,7 +999,7 @@ describe("buildGitActionProgressStages", () => { "Pushing to origin/feature/test...", "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); }); @@ -944,7 +1015,7 @@ describe("resolveThreadBranchUpdate", () => { commit: { status: "created", commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + subject: "feat: add ref sync", }, push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, @@ -968,7 +1039,7 @@ describe("resolveThreadBranchUpdate", () => { commit: { status: "created", commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + subject: "feat: add ref sync", }, push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, @@ -985,8 +1056,8 @@ describe("resolveThreadBranchUpdate", () => { describe("resolveLiveThreadBranchUpdate", () => { it("returns a branch update when live git status differs from stored thread metadata", () => { const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", - gitStatus: status({ branch: "effect-atom" }), + threadBranch: "feature/old-ref", + gitStatus: status({ refName: "effect-atom" }), }); assert.deepEqual(update, { @@ -996,26 +1067,35 @@ describe("resolveLiveThreadBranchUpdate", () => { it("returns null when live git status is unavailable", () => { const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", + threadBranch: "feature/old-ref", gitStatus: null, }); assert.equal(update, null); }); - it("returns null when the stored thread branch already matches git status", () => { + it("returns null when the stored thread ref already matches git status", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", - gitStatus: status({ branch: "effect-atom" }), + gitStatus: status({ refName: "effect-atom" }), }); assert.equal(update, null); }); - it("returns null when git status is detached HEAD but the thread already has a branch", () => { + it("returns null when git status is detached HEAD but the thread already has a ref", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", - gitStatus: status({ branch: null }), + gitStatus: status({ refName: null }), + }); + + assert.equal(update, null); + }); + + it("does not regress a semantic thread ref back to a temporary worktree ref", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "t3code/github-query-rate-limit", + gitStatus: status({ refName: "t3code/bda76797" }), }); assert.equal(update, null); @@ -1023,31 +1103,31 @@ describe("resolveLiveThreadBranchUpdate", () => { }); describe("resolveAutoFeatureBranchName", () => { - it("uses semantic preferred branch names when available", () => { - const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); - assert.equal(branch, "feature/fix-toast-copy"); + it("uses semantic preferred ref names when available", () => { + const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); + assert.equal(ref, "feature/fix-toast-copy"); }); - it("normalizes preferred names that already include a branch namespace", () => { - const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); - assert.equal(branch, "feature/refine-toolbar-actions"); + it("normalizes preferred names that already include a ref namespace", () => { + const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); + assert.equal(ref, "feature/refine-toolbar-actions"); }); - it("increments suffix when the preferred branch name already exists", () => { - const branch = resolveAutoFeatureBranchName( + it("increments suffix when the preferred ref name already exists", () => { + const ref = resolveAutoFeatureBranchName( ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], "fix toast copy", ); - assert.equal(branch, "feature/fix-toast-copy-3"); + assert.equal(ref, "feature/fix-toast-copy-3"); }); - it("treats existing branch names as case-insensitive for collision checks", () => { - const branch = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); - assert.equal(branch, "feature/ticket-1-2"); + it("treats existing ref names as case-insensitive for collision checks", () => { + const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); + assert.equal(ref, "feature/ticket-1-2"); }); it("falls back to feature/update when no preferred name is provided", () => { - const branch = resolveAutoFeatureBranchName(["main"]); - assert.equal(branch, "feature/update"); + const ref = resolveAutoFeatureBranchName(["main"]); + assert.equal(ref, "feature/update"); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index b4b0b98b0b1..3f6bae61cdd 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,8 +1,14 @@ import type { GitRunStackedActionResult, GitStackedAction, - GitStatusResult, + VcsStatusResult, } from "@t3tools/contracts"; +import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; +import { + DEFAULT_CHANGE_REQUEST_TERMINOLOGY, + getChangeRequestTerminology, + type ChangeRequestTerminology, +} from "../sourceControlPresentation"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -20,7 +26,7 @@ export interface GitActionMenuItem { export interface GitQuickAction { label: string; disabled: boolean; - kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; + kind: "run_action" | "run_pull" | "open_pr" | "open_publish" | "show_hint"; action?: GitStackedAction; hint?: string; } @@ -37,6 +43,14 @@ export type DefaultBranchConfirmableAction = | "commit_push" | "commit_push_pr"; +function resolveChangeRequestTerminology( + gitStatus: VcsStatusResult | null, +): ChangeRequestTerminology { + return gitStatus?.sourceControlProvider + ? getChangeRequestTerminology(gitStatus.sourceControlProvider) + : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; +} + export function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; @@ -44,13 +58,15 @@ export function buildGitActionProgressStages(input: { pushTarget?: string; featureBranch?: boolean; shouldPushBeforePr?: boolean; + terminology?: ChangeRequestTerminology; }): string[] { - const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; + const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ - "Preparing PR...", - "Generating PR content...", - "Creating GitHub pull request...", + `Preparing ${terminology.shortLabel}...`, + `Generating ${terminology.shortLabel} content...`, + `Creating ${terminology.singular}...`, ]; if (input.action === "push") { @@ -76,22 +92,23 @@ export function buildGitActionProgressStages(input: { } export function buildMenuItems( - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, isBusy: boolean, - hasOriginRemote = true, + hasPrimaryRemote = true, ): GitActionMenuItem[] { if (!gitStatus) return []; + const terminology = resolveChangeRequestTerminology(gitStatus); - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; - const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; + const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; + const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; const canPush = !isBusy && hasBranch && - !hasChanges && !isBehind && gitStatus.aheadCount > 0 && (gitStatus.hasUpstream || canPushWithoutUpstream); @@ -100,20 +117,26 @@ export function buildMenuItems( hasBranch && !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + hasDefaultBranchDelta && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; + const commitItem: GitActionMenuItem = { + id: "commit", + label: "Commit", + disabled: !canCommit, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }; + + if (!hasPrimaryRemote) { + return [commitItem]; + } + return [ - { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, + commitItem, { id: "push", label: "Push", @@ -125,14 +148,14 @@ export function buildMenuItems( hasOpenPr ? { id: "pr", - label: "View PR", + label: `View ${terminology.shortLabel}`, disabled: !canOpenPr, icon: "pr", kind: "open_pr", } : { id: "pr", - label: "Create PR", + label: `Create ${terminology.shortLabel}`, disabled: !canCreatePr, icon: "pr", kind: "open_dialog", @@ -142,10 +165,10 @@ export function buildMenuItems( } export function resolveQuickAction( - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, isBusy: boolean, - isDefaultBranch = false, - hasOriginRemote = true, + isDefaultRef = false, + hasPrimaryRemote = true, ): GitQuickAction { if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; @@ -160,31 +183,33 @@ export function resolveQuickAction( }; } - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; + const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; + const terminology = resolveChangeRequestTerminology(gitStatus); if (!hasBranch) { return { label: "Commit", disabled: true, kind: "show_hint", - hint: "Create and checkout a branch before pushing or opening a PR.", + hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, }; } if (hasChanges) { - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } return { - label: "Commit, push & PR", + label: `Commit, push & ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "commit_push_pr", @@ -192,20 +217,19 @@ export function resolveQuickAction( } if (!gitStatus.hasUpstream) { - if (!hasOriginRemote) { + if (!hasPrimaryRemote) { if (hasOpenPr && !isAhead) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { - label: "Push", - disabled: true, - kind: "show_hint", - hint: 'Add an "origin" remote before pushing or creating a PR.', + label: "Publish repository", + disabled: false, + kind: "open_publish", }; } if (!isAhead) { if (hasOpenPr) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { label: "Push", @@ -214,16 +238,16 @@ export function resolveQuickAction( hint: "No local commits to push.", }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -232,7 +256,7 @@ export function resolveQuickAction( if (isDiverged) { return { - label: "Sync branch", + label: "Sync ref", disabled: true, kind: "show_hint", hint: "Branch has diverged from upstream. Rebase/merge first.", @@ -248,16 +272,16 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -265,7 +289,16 @@ export function resolveQuickAction( } if (hasOpenPr && gitStatus.hasUpstream) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; + } + + if (hasDefaultBranchDelta && !isDefaultRef) { + return { + label: `Create ${terminology.shortLabel}`, + disabled: false, + kind: "run_action", + action: "create_pr", + }; } return { @@ -278,9 +311,9 @@ export function resolveQuickAction( export function requiresDefaultBranchConfirmation( action: GitStackedAction, - isDefaultBranch: boolean, + isDefaultRef: boolean, ): boolean { - if (!isDefaultBranch) return false; + if (!isDefaultRef) return false; return ( action === "push" || action === "create_pr" || @@ -293,20 +326,22 @@ export function resolveDefaultBranchActionDialogCopy(input: { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; + terminology?: ChangeRequestTerminology; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; + const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; + const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { - title: "Commit & push to default branch?", + title: "Commit & push to default ref?", description: `This action will commit and push changes${suffix}`, continueLabel: `Commit & push to ${branchLabel}`, }; } return { - title: "Push to default branch?", + title: "Push to default ref?", description: `This action will push local commits${suffix}`, continueLabel: `Push to ${branchLabel}`, }; @@ -314,15 +349,15 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: "Commit, push & create PR from default branch?", - description: `This action will commit, push, and create a PR${suffix}`, - continueLabel: `Commit, push & create PR`, + title: `Commit, push & create ${terminology.shortLabel} from default ref?`, + description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, + continueLabel: `Commit, push & create ${terminology.shortLabel}`, }; } return { - title: "Push & create PR from default branch?", - description: `This action will push local commits and create a PR${suffix}`, - continueLabel: "Push & create PR", + title: `Push & create ${terminology.shortLabel} from default ref?`, + description: `This action will push local commits and create a ${terminology.singular}${suffix}`, + continueLabel: `Push & create ${terminology.shortLabel}`, }; } @@ -340,22 +375,31 @@ export function resolveThreadBranchUpdate( export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; - gitStatus: GitStatusResult | null; + gitStatus: VcsStatusResult | null; }): { branch: string | null } | null { if (!input.gitStatus) { return null; } - if (input.gitStatus.branch === null && input.threadBranch !== null) { + if (input.gitStatus.refName === null && input.threadBranch !== null) { + return null; + } + + if (input.threadBranch === input.gitStatus.refName) { return null; } - if (input.threadBranch === input.gitStatus.branch) { + if ( + input.threadBranch !== null && + input.gitStatus.refName !== null && + !isTemporaryWorktreeBranch(input.threadBranch) && + isTemporaryWorktreeBranch(input.gitStatus.refName) + ) { return null; } return { - branch: input.gitStatus.branch, + branch: input.gitStatus.refName, }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6d2312e4aac..341aaed9f1b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,12 +3,33 @@ import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, - GitStatusResult, + SourceControlCloneProtocol, + SourceControlProviderDiscoveryItem, + SourceControlProviderKind, + SourceControlPublishRepositoryResult, + SourceControlRepositoryVisibility, + VcsStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import * as Option from "effect/Option"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; -import { GitHubIcon } from "./Icons"; +import { flushSync } from "react-dom"; +import { + CheckIcon, + ChevronDownIcon, + CloudUploadIcon, + ExternalLinkIcon, + GitCommitIcon, + InfoIcon, + LockIcon, + GlobeIcon, +} from "lucide-react"; +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; +import { RadioGroup } from "~/components/ui/radio-group"; +import { Spinner } from "~/components/ui/spinner"; +import { cn } from "~/lib/utils"; import { buildGitActionProgressStages, buildMenuItems, @@ -22,6 +43,7 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; +import { AnimatedHeight } from "./AnimatedHeight"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -34,24 +56,29 @@ import { DialogTitle, } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; +import { Input } from "~/components/ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; -import { toastManager, type ThreadToastData } from "~/components/ui/toast"; +import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, + sourceControlPublishRepositoryMutationOptions, } from "~/lib/gitReactQuery"; import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; +import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; +import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; @@ -70,6 +97,11 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } +type PublishProviderKind = Extract< + SourceControlProviderKind, + "github" | "gitlab" | "bitbucket" | "azure-devops" +>; + type GitActionToastId = ReturnType; interface ActiveGitActionProgress { @@ -89,7 +121,7 @@ interface RunGitActionWithToastInput { commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; - statusOverride?: GitStatusResult | null; + statusOverride?: VcsStatusResult | null; featureBranch?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; @@ -97,6 +129,88 @@ interface RunGitActionWithToastInput { const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; +const PUBLISH_PROVIDER_OPTIONS = [ + { + value: "github", + label: "GitHub", + description: "github.com", + host: "github.com", + pathPlaceholder: "owner/repo", + Icon: GitHubIcon, + }, + { + value: "gitlab", + label: "GitLab", + description: "gitlab.com", + host: "gitlab.com", + pathPlaceholder: "group/project", + Icon: GitLabIcon, + }, + { + value: "bitbucket", + label: "Bitbucket", + description: "bitbucket.org", + host: "bitbucket.org", + pathPlaceholder: "workspace/repository", + Icon: BitbucketIcon, + }, + { + value: "azure-devops", + label: "Azure DevOps", + description: "dev.azure.com", + host: "dev.azure.com", + pathPlaceholder: "project/repository", + Icon: AzureDevOpsIcon, + }, +] as const satisfies ReadonlyArray<{ + readonly value: PublishProviderKind; + readonly label: string; + readonly description: string; + readonly host: string; + readonly pathPlaceholder: string; + readonly Icon: typeof GitHubIcon; +}>; + +function publishProviderOption(provider: PublishProviderKind) { + return ( + PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? + PUBLISH_PROVIDER_OPTIONS[0] + ); +} + +function isPublishProviderKind( + provider: SourceControlProviderKind, +): provider is PublishProviderKind { + return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); +} + +function getPublishProviderReadiness(input: { + provider: PublishProviderKind; + sourceControlProviders: ReadonlyArray; +}): { readonly ready: boolean; readonly hint: string | null } { + const discovered = input.sourceControlProviders.find( + (provider) => provider.kind === input.provider, + ); + if (!discovered) { + return { + ready: false, + hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", + }; + } + if (discovered.status !== "available") { + return { ready: false, hint: discovered.installHint }; + } + if (discovered.auth.status === "unauthenticated") { + return { + ready: false, + hint: + Option.getOrNull(discovered.auth.detail) ?? + `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, + }; + } + return { ready: true, hint: null }; +} + function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -121,22 +235,23 @@ function getMenuActionDisabledReason({ item, gitStatus, isBusy, - hasOriginRemote, + hasPrimaryRemote, }: { item: GitActionMenuItem; - gitStatus: GitStatusResult | null; + gitStatus: VcsStatusResult | null; isBusy: boolean; - hasOriginRemote: boolean; + hasPrimaryRemote: boolean; }): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; if (!gitStatus) return "Git status is unavailable."; - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; + const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; if (item.id === "commit") { if (!hasChanges) { @@ -147,7 +262,7 @@ function getMenuActionDisabledReason({ if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a branch before pushing."; + return "Detached HEAD: checkout a refName before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; @@ -155,7 +270,7 @@ function getMenuActionDisabledReason({ if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return 'Add an "origin" remote before pushing.'; } if (!isAhead) { @@ -165,51 +280,671 @@ function getMenuActionDisabledReason({ } if (hasOpenPr) { - return "View PR is currently unavailable."; + return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return "Detached HEAD: checkout a branch before creating a PR."; + return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; } if (hasChanges) { - return "Commit local changes before creating a PR."; + return `Commit local changes before creating a ${terminology.singular}.`; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { - return 'Add an "origin" remote before creating a PR.'; + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return `Add an "origin" remote before creating a ${terminology.singular}.`; } if (!isAhead) { - return "No local commits to include in a PR."; + return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { - return "Branch is behind upstream. Pull/rebase before creating a PR."; + return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; } - return "Create PR is currently unavailable."; + return `Create ${terminology.singular} is currently unavailable.`; } const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; -function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { +function GitActionItemIcon({ + icon, + SourceControlIcon, +}: { + icon: GitActionIconName; + SourceControlIcon: ReturnType["Icon"]; +}) { if (icon === "commit") return ; if (icon === "push") return ; - return ; + return ; } -function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { +function GitQuickActionIcon({ + quickAction, + SourceControlIcon, +}: { + quickAction: GitQuickAction; + SourceControlIcon: ReturnType["Icon"]; +}) { const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_publish") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { return ; } - return ; + return ; } if (quickAction.label === "Commit") return ; return ; } +interface PublishRepositoryDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: ScopedThreadRef["environmentId"] | null; + readonly gitCwd: string; +} + +function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const sourceControlDiscovery = useSourceControlDiscovery(); + const [publishProvider, setPublishProvider] = useState("github"); + const [publishRepository, setPublishRepository] = useState(""); + const [publishVisibility, setPublishVisibility] = + useState("private"); + const [publishRemoteName, setPublishRemoteName] = useState("origin"); + const [publishProtocol, setPublishProtocol] = useState("ssh"); + const [publishWizardStep, setPublishWizardStep] = useState(0); + const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); + const [publishError, setPublishError] = useState(null); + const [publishResult, setPublishResult] = useState( + null, + ); + const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); + const publishRepositoryMutation = useMutation( + sourceControlPublishRepositoryMutationOptions({ + environmentId: props.environmentId, + cwd: props.gitCwd, + queryClient, + }), + ); + const publishAccountByProvider = useMemo(() => { + const accounts: Record = { + github: null, + gitlab: null, + bitbucket: null, + "azure-devops": null, + }; + for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { + if (isPublishProviderKind(provider.kind)) { + accounts[provider.kind] = Option.getOrNull(provider.auth.account); + } + } + return accounts; + }, [sourceControlDiscovery.data]); + const publishProviderReadiness = useMemo(() => { + const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; + return Object.fromEntries( + PUBLISH_PROVIDER_OPTIONS.map((option) => [ + option.value, + getPublishProviderReadiness({ + provider: option.value, + sourceControlProviders, + }), + ]), + ) as Record; + }, [sourceControlDiscovery.data]); + const hasReadyPublishProvider = useMemo( + () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), + [publishProviderReadiness], + ); + const sortedPublishProviderOptions = useMemo( + () => + PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { + const leftReady = publishProviderReadiness[left.value].ready; + const rightReady = publishProviderReadiness[right.value].ready; + if (leftReady !== rightReady) { + return leftReady ? -1 : 1; + } + return left.label.localeCompare(right.label); + }), + [publishProviderReadiness], + ); + const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; + const publishRepositoryPrefill = publishAccountByProvider[publishProvider] + ? `${publishAccountByProvider[publishProvider]}/` + : ""; + const currentPublishProvider = publishProviderOption(publishProvider); + const publishHost = currentPublishProvider.host; + const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; + const publishProviderLabel = currentPublishProvider.label; + const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; + const publishWizardStepSummaries = [ + publishProviderLabel, + publishResult?.repository.nameWithOwner ?? null, + null, + ] as const; + + useEffect(() => { + if (!props.open || hasUserEditedPublishRepository) { + return; + } + setPublishRepository(publishRepositoryPrefill); + }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); + + const canSubmitPublishRepository = useMemo(() => { + if (!selectedPublishProviderReadiness.ready) return false; + if (publishRepositoryMutation.isPending) return false; + const repositoryParts = publishRepository.trim().split("/"); + const owner = repositoryParts[0]?.trim() ?? ""; + const rest = repositoryParts.slice(1); + const name = rest.join("/").trim(); + return owner.length > 0 && name.length > 0; + }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); + + useEffect(() => { + if (!props.open) { + return; + } + if (publishProviderReadiness[publishProvider].ready) { + return; + } + const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( + (option) => publishProviderReadiness[option.value].ready, + ); + if (firstReadyProvider) { + setPublishProvider(firstReadyProvider.value); + } + }, [props.open, publishProvider, publishProviderReadiness]); + + const submitPublishRepository = useCallback(() => { + if (!canSubmitPublishRepository) { + return; + } + + setPublishError(null); + + void publishRepositoryMutation + .mutateAsync({ + provider: publishProvider, + repository: publishRepository.trim(), + visibility: publishVisibility, + remoteName: publishRemoteName.trim() || "origin", + protocol: publishProtocol, + }) + .then((result) => { + flushSync(() => { + setPublishResult(result); + setPublishWizardStep(2); + }); + void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( + () => undefined, + ); + }) + .catch((err: unknown) => { + setPublishError(err instanceof Error ? err.message : "An error occurred."); + }); + }, [ + canSubmitPublishRepository, + props.environmentId, + props.gitCwd, + publishProtocol, + publishProvider, + publishRemoteName, + publishRepository, + publishRepositoryMutation, + publishVisibility, + ]); + + const resetState = useCallback(() => { + setPublishRemoteName("origin"); + setPublishRepository(""); + setHasUserEditedPublishRepository(false); + setPublishWizardStep(0); + setPublishAdvancedOpen(false); + setPublishError(null); + setPublishResult(null); + }, []); + + const handleOpenChange = useCallback( + (open: boolean) => { + props.onOpenChange(open); + if (!open) { + resetState(); + } + }, + [props, resetState], + ); + + const openSourceControlSettings = useCallback(() => { + handleOpenChange(false); + void navigate({ to: "/settings/source-control" }); + }, [handleOpenChange, navigate]); + + return ( + + +
    + + Publish repository + + Pick where to host it, then point us at a repo to push to. + +
    + {publishWizardSteps.map((label, index) => { + const isComplete = index < publishWizardStep; + const isClickable = + publishWizardStep !== 2 && + index < publishWizardSteps.length - 1 && + index <= publishWizardStep; + return ( + + ); + })} +
    +
    + + + +
    + + Provider + + setPublishProvider(value as PublishProviderKind)} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {sortedPublishProviderOptions.map((option) => { + const readiness = publishProviderReadiness[option.value]; + const isSelected = publishProvider === option.value && readiness.ready; + if (!readiness.ready) { + return ( +
    + + + {option.label} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {readiness.hint ?? + "Open Settings -> Source Control to configure this provider."} + + +
    + ); + } + + return ( + + + + {option.label} + + + ); + })} +
    +
    + +
    +
    + +
    + + + {publishHost}/ + + { + setPublishRepository(event.target.value); + setHasUserEditedPublishRepository(true); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); + } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryMutation.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
    +
    + +
    + + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + + {option.label} + + + {option.description} + + + + ); + })} + +
    + +
    + + {publishAdvancedOpen ? ( +
    + +
    + + Protocol + + + setPublishProtocol(value as SourceControlCloneProtocol) + } + aria-labelledby="publish-protocol-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2" + > + {(["ssh", "https"] as const).map((value) => { + const isSelected = publishProtocol === value; + return ( + + {value === "ssh" ? "SSH" : "HTTPS"} + + ); + })} + +
    +
    + ) : null} +
    + + {publishRepositoryMutation.isPending ? ( +
    + + Publishing repository to {publishProviderLabel}... +
    + ) : null} + {publishError && !publishRepositoryMutation.isPending ? ( +
    +

    Publish failed

    +

    {publishError}

    +
    + ) : null} +
    + +
    + {publishResult ? ( + <> +
    + + + +

    + {publishResult.status === "pushed" + ? "Repository published" + : "Repository created"} +

    +

    + {publishResult.status === "pushed" + ? `${publishResult.branch} is now live on ${publishProviderLabel}.` + : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} +

    +
    +
    + + + {publishResult.repository.nameWithOwner} + +
    + + + ) : ( +
    + Publish result unavailable. +
    + )} +
    +
    +
    + + + {publishWizardStep === 2 ? ( + + ) : ( + <> + + {publishWizardStep < 1 ? ( + + ) : ( + + )} + + )} + +
    +
    +
    + ); +} + export default function GitActionsControl({ gitCwd, activeThreadRef, @@ -239,6 +974,7 @@ export default function GitActionsControl({ const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); + const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); const activeGitActionProgressRef = useRef(null); @@ -322,9 +1058,15 @@ export default function GitActionsControl({ environmentId: activeEnvironmentId, cwd: gitCwd, }); + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(gitStatus?.sourceControlProvider), + [gitStatus?.sourceControlProvider], + ); + const changeRequestTerminology = sourceControlPresentation.terminology; + const SourceControlIcon = sourceControlPresentation.Icon; // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; - const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; + const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; @@ -353,7 +1095,11 @@ export default function GitActionsControl({ }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; - const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + const isPublishRunning = + useIsMutating({ + mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd), + }) > 0; + const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning; const isSelectingWorktreeBase = !activeServerThread && activeDraftThread?.envMode === "worktree" && @@ -382,18 +1128,18 @@ export default function GitActionsControl({ persistThreadBranchSync, ]); - const isDefaultBranch = useMemo(() => { - return gitStatusForActions?.isDefaultBranch ?? false; - }, [gitStatusForActions?.isDefaultBranch]); + const isDefaultRef = useMemo(() => { + return gitStatusForActions?.isDefaultRef ?? false; + }, [gitStatusForActions?.isDefaultRef]); const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isGitActionRunning], + () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], ); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], + resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -403,6 +1149,7 @@ export default function GitActionsControl({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, + terminology: changeRequestTerminology, }) : null; @@ -468,18 +1215,20 @@ export default function GitActionsControl({ if (!prUrl) { toastManager.add({ type: "error", - title: "No open PR found.", + title: "No open pull request found.", data: threadToastData, }); return; } void api.shell.openExternal(prUrl).catch((err: unknown) => { - toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open pull request link", + description: err instanceof Error ? err.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); }); }, [gitStatusForActions, threadToastData]); @@ -495,8 +1244,8 @@ export default function GitActionsControl({ filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; - const actionBranch = actionStatus?.branch ?? null; - const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; + const actionBranch = actionStatus?.refName ?? null; + const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; const actionCanCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = @@ -532,6 +1281,7 @@ export default function GitActionsControl({ hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, featureBranch, + terminology: changeRequestTerminology, shouldPushBeforePr: action === "create_pr" && (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), @@ -670,33 +1420,43 @@ export default function GitActionsControl({ }; } - const successToastBase = { - type: "success", - title: result.toast.title, - description: result.toast.description, - timeout: 0, - data: { - ...scopedToastData, - dismissAfterVisibleMs: 10_000, - }, - } as const; + const successToastData = { + ...scopedToastData, + dismissAfterVisibleMs: 10_000, + }; if (toastActionProps) { + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "success", + title: result.toast.title, + description: result.toast.description, + timeout: 0, + actionProps: toastActionProps, + data: successToastData, + }), + ); + } else { toastManager.update(resolvedProgressToastId, { - ...successToastBase, - actionProps: toastActionProps, + type: "success", + title: result.toast.title, + description: result.toast.description, + timeout: 0, + data: successToastData, }); - } else { - toastManager.update(resolvedProgressToastId, successToastBase); } } catch (err) { activeGitActionProgressRef.current = null; - toastManager.update(resolvedProgressToastId, { - type: "error", - title: "Action failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: scopedToastData, - }); + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "error", + title: "Action failed", + description: err instanceof Error ? err.message : "An error occurred.", + ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), + }), + ); } }, ); @@ -751,16 +1511,23 @@ export default function GitActionsControl({ void openExistingPr(); return; } + if (quickAction.kind === "open_publish") { + setIsPublishDialogOpen(true); + return; + } if (quickAction.kind === "run_pull") { const promise = pullMutation.mutateAsync(); - toastManager.promise(promise, { + void toastManager.promise< + Awaited>, + ThreadToastData + >(promise, { loading: { title: "Pulling...", data: threadToastData }, success: (result) => ({ title: result.status === "pulled" ? "Pulled" : "Already up to date", description: result.status === "pulled" - ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` - : `${result.branch} is already synchronized.`, + ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` + : `${result.refName} is already synchronized.`, data: threadToastData, }), error: (err) => ({ @@ -832,17 +1599,21 @@ export default function GitActionsControl({ } const target = resolvePathLinkTarget(filePath, gitCwd); void openInPreferredEditor(api, target).catch((error) => { - toastManager.add({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - data: threadToastData, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); }); }, [gitCwd, threadToastData], ); + const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; + if (!gitCwd) return null; return ( @@ -871,7 +1642,10 @@ export default function GitActionsControl({ /> } > - + {quickAction.label} @@ -887,7 +1661,7 @@ export default function GitActionsControl({ disabled={isGitActionRunning || quickAction.disabled} onClick={runQuickAction} > - + {quickAction.label} @@ -916,7 +1690,7 @@ export default function GitActionsControl({ item, gitStatus: gitStatusForActions, isBusy: isGitActionRunning, - hasOriginRemote, + hasPrimaryRemote, }); if (item.disabled && disabledReason) { return ( @@ -927,7 +1701,10 @@ export default function GitActionsControl({ render={} > - + {item.label} @@ -946,18 +1723,30 @@ export default function GitActionsControl({ openDialogForMenuItem(item); }} > - + {item.label} ); })} - {gitStatusForActions?.branch === null && ( + {canPublishRepository ? ( + { + setIsPublishDialogOpen(true); + }} + > + + Publish repository... + + ) : null} + {gitStatusForActions?.refName === null && (

    - Detached HEAD: create and checkout a branch to enable push and PR actions. + Detached HEAD: create and checkout a refName to enable push and pull request + actions.

    )} {gitStatusForActions && - gitStatusForActions.branch !== null && + gitStatusForActions.refName !== null && !gitStatusForActions.hasWorkingTreeChanges && gitStatusForActions.behindCount > 0 && gitStatusForActions.aheadCount === 0 && ( @@ -995,10 +1784,12 @@ export default function GitActionsControl({ Branch - {gitStatusForActions?.branch ?? "(detached HEAD)"} + {gitStatusForActions?.refName ?? "(detached HEAD)"} - {isDefaultBranch && ( - Warning: default branch + {isDefaultRef && ( + + Warning: default refName + )}
    @@ -1131,7 +1922,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new branch + Commit on new refName - - diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 37a47bb01b0..b3211e17753 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -1,4 +1,5 @@ -import { type SVGProps, useId } from "react"; +import React, { type SVGProps, useId } from "react"; +import { cn } from "~/lib/utils"; export type Icon = React.FC>; @@ -14,8 +15,186 @@ export const GitHubIcon: Icon = (props) => ( ); -export const CursorIcon: Icon = (props) => ( - +export const GitIcon: Icon = (props) => ( + + + +); + +export const JujutsuIcon: Icon = (props) => { + const groupId = `${useId().replaceAll(":", "")}-jj-a`; + + return ( + + + + + + + + + + + + + + + + + ); +}; + +export const GitLabIcon: Icon = (props) => ( + + + + + + +); + +export const AzureDevOpsIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientA = `${id}-azure-a`; + const gradientB = `${id}-azure-b`; + const gradientC = `${id}-azure-c`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const BitbucketIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientId = `${id}-bitbucket-a`; + + return ( + + + + + + + + + + ); +}; + +export const CursorIcon: Icon = ({ className, ...props }) => ( + ); @@ -34,6 +213,24 @@ export const TraeIcon: Icon = (props) => ( ); +export const KiroIcon: Icon = (props) => ( + + + + + + +); + export const VisualStudioCode: Icon = (props) => { const id = useId(); const maskId = `${id}-vscode-a`; @@ -272,18 +469,25 @@ export const Zed: Icon = (props) => { ); }; -export const OpenAI: Icon = (props) => ( - +export const OpenAI: Icon = ({ className, ...props }) => ( + ); -export const ClaudeAI: Icon = (props) => ( - - +export const ClaudeAI: Icon = ({ className, ...props }) => ( + + ); @@ -431,112 +635,13 @@ export const AntigravityIcon: Icon = (props) => ( ); -export const IntelliJIdeaIcon: Icon = (props) => { - const id = useId(); - const gradientAId = `${id}-idea-a`; - const gradientBId = `${id}-idea-b`; - const gradientCId = `${id}-idea-c`; - const gradientDId = `${id}-idea-d`; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - export const OpenCodeIcon: Icon = (props) => ( - - + + + + @@ -545,3 +650,37 @@ export const OpenCodeIcon: Icon = (props) => ( ); + +export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( + + + +); + +export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( + + + +); + +export const PiAgentIcon: Icon = ({ className, ...props }) => ( + + + + + +); diff --git a/apps/web/src/components/JetBrainsIcons.tsx b/apps/web/src/components/JetBrainsIcons.tsx new file mode 100644 index 00000000000..85efa50bedc --- /dev/null +++ b/apps/web/src/components/JetBrainsIcons.tsx @@ -0,0 +1,610 @@ +import { useId } from "react"; +import type { Icon } from "./Icons"; + +const useSvgGradientIds = (prefix: string, count: number) => { + const id = useId(); + return Array.from( + { length: count }, + (_, index) => `${id}-${prefix}-${String.fromCharCode(97 + index)}`, + ); +}; + +export const AquaIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("aqua", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const CLionIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("clion", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const DataGripIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("datagrip", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const DataSpellIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("dataspell", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const GoLandIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("goland", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const IntelliJIdeaIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("intellij-idea", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const PhpStormIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("phpstorm", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const PyCharmIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("pycharm", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const RiderIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("rider", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const RubyMineIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("rubymine", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const RustRoverIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("rustrover", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const WebStormIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("webstorm", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 223f5d8ebdc..611eaf572d0 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -7,14 +7,19 @@ import { type MessageId, type OrchestrationReadModel, type ProjectId, + ProviderDriverKind, + ProviderInstanceId, type ServerConfig, type ServerLifecycleWelcomePayload, + ServerConfig as ServerConfigSchema, + ServerSettings, type ThreadId, WS_METHODS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { ws, http, HttpResponse } from "msw"; import { setupWorker } from "msw/browser"; +import * as Schema from "effect/Schema"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -48,6 +53,8 @@ interface TestFixture { let fixture: TestFixture; const rpcHarness = new BrowserWsRpcHarness(); +const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); +const encodeServerSettings = Schema.encodeSync(ServerSettings); const wsLink = ws.link(/ws(s)?:\/\/.*/); @@ -72,7 +79,8 @@ function createBaseServerConfig(): ServerConfig { issues: [], providers: [ { - provider: "codex", + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), enabled: true, installed: true, version: "0.116.0", @@ -95,10 +103,33 @@ function createBaseServerConfig(): ServerConfig { ...DEFAULT_SERVER_SETTINGS, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, - textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4-mini", + }, providers: { - codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, - claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + codex: { + enabled: true, + binaryPath: "", + homePath: "", + shadowHomePath: "", + customModels: [], + }, + claudeAgent: { + enabled: true, + binaryPath: "", + homePath: "", + customModels: [], + launchArgs: "", + }, + cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, + opencode: { + enabled: true, + binaryPath: "", + serverUrl: "", + serverPassword: "", + customModels: [], + }, }, }, }; @@ -113,7 +144,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, scripts: [], @@ -128,7 +159,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { projectId: PROJECT_ID, title: "Test thread", modelSelection: { - provider: "codex", + instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, interactionMode: "default", @@ -169,6 +200,43 @@ function createMinimalSnapshot(): OrchestrationReadModel { }; } +function toShellSnapshot(snapshot: OrchestrationReadModel) { + return { + snapshotSequence: snapshot.snapshotSequence, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection, + scripts: project.scripts, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })), + threads: snapshot.threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + session: thread.session, + latestUserMessageAt: + thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + })), + updatedAt: snapshot.updatedAt, + }; +} + function buildFixture(): TestFixture { return { snapshot: createMinimalSnapshot(), @@ -190,19 +258,16 @@ function buildFixture(): TestFixture { } function resolveWsRpc(tag: string): unknown { - if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { - return fixture.snapshot; - } if (tag === WS_METHODS.serverGetConfig) { - return fixture.serverConfig; + return encodeServerConfig(fixture.serverConfig); } - if (tag === WS_METHODS.gitListBranches) { + if (tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, - branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], + refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } if (tag === WS_METHODS.projectsSearchEntries) { @@ -229,7 +294,7 @@ function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { version: 1, type: "keybindingsUpdated", - payload: { issues }, + payload: { keybindings: fixture.serverConfig.keybindings, issues }, }); } @@ -334,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise { rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { version: 1, type: "settingsUpdated", - payload: { settings: fixture.serverConfig.settings }, + payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, }); try { @@ -425,7 +490,29 @@ describe("Keybindings update toast", () => { { version: 1, type: "snapshot", - config: fixture.serverConfig, + config: encodeServerConfig(fixture.serverConfig), + }, + ]; + } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { + return [ + { + kind: "snapshot", + snapshot: toShellSnapshot(fixture.snapshot), + }, + ]; + } + if ( + request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && + request.threadId === THREAD_ID + ) { + return [ + { + kind: "snapshot", + snapshot: { + snapshotSequence: fixture.snapshot.snapshotSequence, + thread: fixture.snapshot.threads[0], + }, }, ]; } @@ -450,16 +537,20 @@ describe("Keybindings update toast", () => { document.body.innerHTML = ""; }); - it("shows a toast for each consecutive keybinding update with no issues", async () => { + it("coalesces rapid consecutive keybinding update toasts with no issues", async () => { const mounted = await mountApp(); try { sendServerConfigUpdatedPush([]); await waitForToast("Keybindings updated", 1); - // Each server push represents a distinct file change, so it should produce its own toast. + // A single edit can produce several reload notifications as the direct update and + // filesystem watcher settle, so avoid stacking identical success toasts. sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated", 2); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const titles = queryToastTitles(); + expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 39aa4e2f728..cd1f76ed2c2 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -10,11 +10,15 @@ export function NoActiveThreadState() {
    {isElectron ? ( -
    No active thread + + No active thread + ) : (
    diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 134ca2e6f3f..afd4bb2e0bc 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -26,7 +26,7 @@ import { } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { readEnvironmentApi } from "~/environmentApi"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; function stepStatusIcon(status: string): React.ReactNode { @@ -54,20 +54,24 @@ function stepStatusIcon(status: string): React.ReactNode { interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; + label?: string; environmentId: EnvironmentId; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; + mode?: "sheet" | "sidebar"; onClose: () => void; } const PlanSidebar = memo(function PlanSidebar({ activePlan, activeProposedPlan, + label = "Plan", environmentId, markdownCwd, workspaceRoot, timestampFormat, + mode = "sidebar", onClose, }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); @@ -108,11 +112,13 @@ const PlanSidebar = memo(function PlanSidebar({ }); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save plan", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }) .then( () => setIsSavingToWorkspace(false), @@ -121,7 +127,14 @@ const PlanSidebar = memo(function PlanSidebar({ }, [environmentId, planMarkdown, workspaceRoot]); return ( -
    +
    {/* Header */}
    @@ -129,7 +142,7 @@ const PlanSidebar = memo(function PlanSidebar({ variant="secondary" className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase" > - Plan + {label} {activePlan ? ( @@ -170,7 +183,7 @@ const PlanSidebar = memo(function PlanSidebar({ size="icon-xs" variant="ghost" onClick={onClose} - aria-label="Close plan sidebar" + aria-label={`Close ${label.toLowerCase()} sidebar`} className="text-muted-foreground/50 hover:text-foreground/70" > diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 38e07f59ced..ad47e01bb11 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -10,15 +10,29 @@ export function ProjectFavicon(input: { cwd: string; className?: string; }) { - const src = resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd }, - }); + const src = (() => { + try { + return resolveEnvironmentHttpUrl({ + environmentId: input.environmentId, + pathname: "/api/project-favicon", + searchParams: { cwd: input.cwd }, + }); + } catch { + return null; + } + })(); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", ); + if (!src) { + return ( + + ); + } + return ( <> {status !== "loaded" ? ( diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 11b08cc2cf4..4a9cda19d65 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -20,13 +20,13 @@ import { keybindingValueForCommand, decodeProjectScriptKeybindingRule, } from "~/lib/projectScriptKeybindings"; +import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; -import { isMacPlatform } from "~/lib/utils"; import { AlertDialog, AlertDialogClose, @@ -96,57 +96,6 @@ interface ProjectScriptsControlProps { onDeleteScript: (scriptId: string) => Promise | void; } -function normalizeShortcutKeyToken(key: string): string | null { - const normalized = key.toLowerCase(); - if ( - normalized === "meta" || - normalized === "control" || - normalized === "ctrl" || - normalized === "shift" || - normalized === "alt" || - normalized === "option" - ) { - return null; - } - if (normalized === " ") return "space"; - if (normalized === "escape") return "esc"; - if (normalized === "arrowup") return "arrowup"; - if (normalized === "arrowdown") return "arrowdown"; - if (normalized === "arrowleft") return "arrowleft"; - if (normalized === "arrowright") return "arrowright"; - if (normalized.length === 1) return normalized; - if (normalized.startsWith("f") && normalized.length <= 3) return normalized; - if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { - return normalized; - } - if (normalized === "delete" || normalized === "home" || normalized === "end") { - return normalized; - } - if (normalized === "pageup" || normalized === "pagedown") return normalized; - return null; -} - -function keybindingFromEvent(event: KeyboardEvent): string | null { - const keyToken = normalizeShortcutKeyToken(event.key); - if (!keyToken) return null; - - const parts: string[] = []; - if (isMacPlatform(navigator.platform)) { - if (event.metaKey) parts.push("mod"); - if (event.ctrlKey) parts.push("ctrl"); - } else { - if (event.ctrlKey) parts.push("mod"); - if (event.metaKey) parts.push("meta"); - } - if (event.altKey) parts.push("alt"); - if (event.shiftKey) parts.push("shift"); - if (parts.length === 0) { - return null; - } - parts.push(keyToken); - return parts.join("+"); -} - export default function ProjectScriptsControl({ scripts, keybindings, @@ -186,7 +135,7 @@ export default function ProjectScriptsControl({ setKeybinding(""); return; } - const next = keybindingFromEvent(event); + const next = keybindingFromKeyboardEvent(event, navigator.platform); if (!next) return; setKeybinding(next); }; diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts new file mode 100644 index 00000000000..defb6eb20bd --- /dev/null +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -0,0 +1,673 @@ +import { describe, expect, it } from "vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; + +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + collectUpdatedProviderSnapshots, + firstRejectedProviderUpdateMessage, + getProviderUpdateInitialToastView, + getProviderUpdateProgressToastView, + getProviderUpdateRejectedToastView, + getProviderUpdateSidebarPillView, + getSingleProviderUpdateProgressToastView, + hasOneClickUpdateProviderCandidate, + isProviderUpdateCandidate, + providerUpdateNotificationKey, + type ProviderUpdateCandidate, +} from "./ProviderUpdateLaunchNotification.logic"; + +const checkedAt = "2026-04-23T10:00:00.000Z"; +const sessionStartedAt = "2026-04-23T09:59:00.000Z"; +const laterCheckedAt = "2026-04-23T10:01:00.000Z"; + +const driver = (value: string) => ProviderDriverKind.make(value); +const instanceId = (value: string) => ProviderInstanceId.make(value); + +function provider(input: { + readonly driver: ReturnType; + readonly instanceId?: ReturnType; + readonly enabled?: boolean; + readonly version?: string | null; + readonly latestVersion?: string | null; + readonly canUpdate?: boolean; + readonly updateCommand?: string | null; + readonly updateState?: ServerProvider["updateState"]; + readonly advisoryStatus?: NonNullable["status"]; +}): ServerProvider { + const result: ServerProvider = { + instanceId: input.instanceId ?? instanceId(String(input.driver)), + driver: input.driver, + enabled: input.enabled ?? true, + installed: true, + version: input.version ?? "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: input.advisoryStatus ?? "behind_latest", + currentVersion: input.version ?? "1.0.0", + latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0", + updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider", + canUpdate: input.canUpdate ?? true, + checkedAt, + message: "Update available.", + }, + }; + + if (input.updateState) { + return { ...result, updateState: input.updateState }; + } + + return result; +} + +function updateCandidate(input: Parameters[0]): ProviderUpdateCandidate { + return provider(input) as ProviderUpdateCandidate; +} + +describe("provider update launch notification logic", () => { + it("detects enabled providers with a latest-version advisory", () => { + expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true); + expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe( + false, + ); + expect( + isProviderUpdateCandidate( + provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), + ), + ).toBe(false); + expect( + isProviderUpdateCandidate(provider({ driver: driver("codex"), latestVersion: null })), + ).toBe(false); + }); + + it("deduplicates multi-instance provider candidates by driver", () => { + expect( + collectProviderUpdateCandidates([ + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_personal"), + latestVersion: "1.1.0", + }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex"), + latestVersion: "1.1.0", + }), + provider({ driver: driver("cursor"), latestVersion: "0.3.0" }), + ]), + ).toHaveLength(2); + }); + + it("disables one-click updates when provider instances disagree on the update command", () => { + const candidate = updateCandidate({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_personal"), + latestVersion: "2.1.123", + }); + + expect( + canOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + latestVersion: "2.1.123", + canUpdate: true, + updateCommand: "bun add -g @anthropic-ai/claude-code@latest", + }), + ]), + ).toBe(false); + }); + + it("keeps one-click updates enabled when sibling instances are already current", () => { + const candidate = updateCandidate({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_personal"), + latestVersion: "2.1.123", + updateCommand: "npm install -g @anthropic-ai/claude-code@latest", + }); + + expect( + hasOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + version: "2.1.123", + latestVersion: "2.1.123", + advisoryStatus: "current", + canUpdate: false, + updateCommand: null, + }), + ]), + ).toBe(true); + expect( + canOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + version: "2.1.123", + latestVersion: "2.1.123", + advisoryStatus: "current", + canUpdate: false, + updateCommand: null, + }), + ]), + ).toBe(true); + }); + + it("keeps the inline update action available while a provider update is already running", () => { + const candidate = updateCandidate({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }); + + expect(hasOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(true); + expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); + }); + + it("builds a notification key from provider latest versions", () => { + const codex = updateCandidate({ + driver: driver("codex"), + version: "1.0.0", + latestVersion: "1.1.0", + }); + const cursor = updateCandidate({ + driver: driver("cursor"), + version: "0.2.0", + latestVersion: "0.3.0", + }); + + expect(providerUpdateNotificationKey([codex, cursor])).toBe("codex:1.1.0|cursor:0.3.0"); + expect(providerUpdateNotificationKey([])).toBeNull(); + }); + + it("keeps the same notification key while the published update version is unchanged", () => { + const first = updateCandidate({ + driver: driver("codex"), + version: "1.0.0", + latestVersion: "1.2.0", + }); + const second = updateCandidate({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.2.0", + }); + const nextPublishedVersion = updateCandidate({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.3.0", + }); + + expect(providerUpdateNotificationKey([first])).toBe(providerUpdateNotificationKey([second])); + expect(providerUpdateNotificationKey([nextPublishedVersion])).not.toBe( + providerUpdateNotificationKey([first]), + ); + }); + + it("tracks updated provider snapshots by instance instead of collapsing to a sibling driver", () => { + const targetInstanceId = instanceId("codex_personal"); + const siblingInstanceId = instanceId("codex"); + const updatedPersonal = provider({ + driver: driver("codex"), + instanceId: targetInstanceId, + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }); + const currentDefaultSibling = provider({ + driver: driver("codex"), + instanceId: siblingInstanceId, + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: undefined, + }); + + expect( + collectUpdatedProviderSnapshots({ + results: [ + { + status: "fulfilled", + value: { + providers: [updatedPersonal, currentDefaultSibling], + }, + }, + ], + providerInstanceIds: new Set([targetInstanceId]), + }), + ).toEqual([updatedPersonal]); + }); + + it("describes a single one-click update", () => { + const view = getProviderUpdateInitialToastView({ + updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], + oneClickProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], + }); + + expect(view).toMatchObject({ + phase: "initial", + type: "warning", + title: "Update Available: Codex v1.1.0", + description: "Install the update now or review provider settings.", + }); + }); + + it("describes settings-only updates without one-click support", () => { + const view = getProviderUpdateInitialToastView({ + updateProviders: [ + updateCandidate({ driver: driver("codex"), canUpdate: false }), + updateCandidate({ driver: driver("cursor"), canUpdate: false }), + ], + oneClickProviders: [], + }); + + expect(view.description).toBe("Codex and Cursor can be updated from provider settings."); + }); + + it("uses server update state for running progress", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "running", + type: "loading", + title: "Updating provider", + }); + }); + + it("uses server failure state for failed progress", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("codex"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "command failed", + output: "stderr", + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "failed", + type: "error", + title: "Provider update failed", + description: "command failed", + }); + }); + + it("resolves a single-provider completion view from the returned provider snapshot", () => { + const view = getSingleProviderUpdateProgressToastView( + provider({ + driver: driver("codex"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "command failed", + output: "stderr", + }, + }), + ); + + expect(view).toMatchObject({ + phase: "failed", + type: "error", + title: "Codex v1.1.0 update failed", + description: "command failed", + }); + }); + + it("keeps unchanged providers actionable from settings", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("cursor"), + updateState: { + status: "unchanged", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "still old", + output: null, + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "unchanged", + type: "warning", + title: "Provider still needs an update", + description: "Cursor still appears outdated. Check provider settings for details.", + }); + }); + + it("marks progress succeeded once every attempted provider is no longer outdated", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "succeeded", + type: "success", + title: "Provider updated", + description: "New sessions will use the updated provider.", + dismissAfterVisibleMs: 3_000, + }); + }); + + it("uses the updated version in the single-provider success toast title", () => { + const view = getSingleProviderUpdateProgressToastView( + provider({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }), + ); + + expect(view).toMatchObject({ + phase: "succeeded", + type: "success", + title: "Codex updated: v1.1.0", + description: "New sessions will use the updated provider.", + }); + }); + + it("falls back to a rejected RPC message for transport-level failures", () => { + const results: PromiseSettledResult[] = [ + { status: "rejected", reason: new Error("WebSocket closed") }, + ]; + + expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); + expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ + phase: "failed", + title: "Provider updates failed", + description: "WebSocket closed", + }); + }); + + it("collects only attempted provider snapshots from update responses", () => { + const codex = provider({ driver: driver("codex") }); + const cursor = provider({ driver: driver("cursor") }); + const results: PromiseSettledResult<{ readonly providers: ReadonlyArray }>[] = [ + { status: "fulfilled", value: { providers: [codex, cursor] } }, + ]; + + expect( + collectUpdatedProviderSnapshots({ + results, + providerInstanceIds: new Set([cursor.instanceId]), + }), + ).toEqual([cursor]); + }); + + it("summarizes active provider updates for the sidebar pill", () => { + const view = getProviderUpdateSidebarPillView([ + provider({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }), + provider({ + driver: driver("cursor"), + updateState: { + status: "queued", + startedAt: null, + finishedAt: null, + message: "Waiting for another provider update to finish.", + output: null, + }, + }), + ]); + + expect(view).toMatchObject({ + tone: "loading", + title: "Updating 2 providers", + description: "Codex and Cursor updates are in progress.", + }); + }); + + it("uses the provider name for single active sidebar pill updates", () => { + const view = getProviderUpdateSidebarPillView([ + provider({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }), + ]); + + expect(view).toMatchObject({ + key: "loading:codex:running", + tone: "loading", + title: "Updating Codex", + description: "Codex update in progress.", + }); + }); + + it("uses the provider name for single failed sidebar pill updates", () => { + const view = getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("claudeAgent"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Update command exited with code 1.", + output: null, + }, + }), + ], + { visibleAfterIso: sessionStartedAt }, + ); + + expect(view).toMatchObject({ + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", + tone: "error", + title: "Claude v1.1.0 update failed", + description: "Update command exited with code 1.", + dismissible: true, + }); + }); + + it("shows a short-lived success sidebar pill after a single provider update succeeds", () => { + const view = getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }), + ], + { visibleAfterIso: sessionStartedAt }, + ); + + expect(view).toMatchObject({ + key: "succeeded:codex:2026-04-23T10:00:00.000Z:Provider updated.", + tone: "success", + title: "Codex updated: v1.1.0", + description: "New sessions will use the updated provider.", + dismissAfterVisibleMs: 3_000, + }); + }); + + it("keeps unchanged sidebar pill states dismissible", () => { + const view = getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("cursor"), + updateState: { + status: "unchanged", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "still old", + output: null, + }, + }), + ], + { visibleAfterIso: sessionStartedAt }, + ); + + expect(view).toMatchObject({ + key: "unchanged:cursor:2026-04-23T10:00:00.000Z:still old", + tone: "warning", + title: "Cursor still needs an update", + dismissible: true, + }); + }); + + it("does not show sidebar terminal states from before the current app session", () => { + expect( + getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("codex"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "command failed", + output: "stderr", + }, + }), + ], + { visibleAfterIso: "2026-04-23T10:00:01.000Z" }, + ), + ).toBeNull(); + }); + + it("shows a newer success before falling back to an older failure", () => { + const providers = [ + provider({ + driver: driver("claudeAgent"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Update command exited with code 1.", + output: null, + }, + }), + provider({ + driver: driver("codex"), + version: "1.2.0", + latestVersion: "1.2.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: laterCheckedAt, + finishedAt: laterCheckedAt, + message: "Provider updated.", + output: null, + }, + }), + ] satisfies ReadonlyArray; + + const successView = getProviderUpdateSidebarPillView(providers, { + visibleAfterIso: sessionStartedAt, + }); + expect(successView).toMatchObject({ + key: "succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated.", + tone: "success", + title: "Codex updated: v1.2.0", + }); + + const failureView = getProviderUpdateSidebarPillView(providers, { + visibleAfterIso: sessionStartedAt, + dismissedKeys: new Set(["succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated."]), + }); + expect(failureView).toMatchObject({ + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", + tone: "error", + title: "Claude v1.1.0 update failed", + }); + }); + + it("does not show a sidebar pill for passive update availability", () => { + expect( + getProviderUpdateSidebarPillView([ + provider({ driver: driver("codex"), canUpdate: true }), + provider({ driver: driver("cursor"), canUpdate: false }), + ]), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts new file mode 100644 index 00000000000..f45b2916ce4 --- /dev/null +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -0,0 +1,537 @@ +import { + defaultInstanceIdForDriver, + PROVIDER_DISPLAY_NAMES, + type ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; + +export type ProviderUpdateCandidate = ServerProvider & { + readonly versionAdvisory: NonNullable & { + readonly status: "behind_latest"; + readonly latestVersion: string; + }; +}; + +export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success"; +export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded"; + +export interface ProviderUpdateToastView { + readonly phase: ProviderUpdateToastPhase; + readonly type: ProviderUpdateToastType; + readonly title: string; + readonly description: string; + readonly dismissAfterVisibleMs?: number; +} + +export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success"; + +export interface ProviderUpdateSidebarPillView { + readonly key: string; + readonly tone: ProviderUpdateSidebarPillTone; + readonly title: string; + readonly description: string; + readonly dismissible?: boolean; + readonly dismissAfterVisibleMs?: number; +} + +interface ProviderUpdateSidebarPillOptions { + readonly visibleAfterIso?: string; + readonly dismissedKeys?: ReadonlySet; +} + +const PROVIDER_UPDATE_SUCCESS_VISIBLE_MS = 3_000; + +function formatVersion(value: string): string { + return value.startsWith("v") ? value : `v${value}`; +} + +function chooseRepresentativeProvider( + current: ServerProvider | undefined, + candidate: ServerProvider, +): ServerProvider { + if (!current) { + return candidate; + } + const defaultInstanceId = defaultInstanceIdForDriver(candidate.driver); + if (candidate.instanceId === defaultInstanceId) { + return candidate; + } + if (current.instanceId === defaultInstanceId) { + return current; + } + return candidate.checkedAt.localeCompare(current.checkedAt) >= 0 ? candidate : current; +} + +function dedupeProvidersByDriver(providers: ReadonlyArray): T[] { + const latestProviderByDriver = new Map(); + + for (const provider of providers) { + latestProviderByDriver.set( + provider.driver, + chooseRepresentativeProvider(latestProviderByDriver.get(provider.driver), provider) as T, + ); + } + + return [...latestProviderByDriver.values()]; +} + +function dedupeProvidersByInstanceId(providers: ReadonlyArray): T[] { + const latestProviderByInstanceId = new Map(); + + for (const provider of providers) { + const current = latestProviderByInstanceId.get(provider.instanceId); + if (!current || provider.checkedAt.localeCompare(current.checkedAt) >= 0) { + latestProviderByInstanceId.set(provider.instanceId, provider); + } + } + + return [...latestProviderByInstanceId.values()]; +} + +function getProviderUpdatedTitle(provider: Pick): string { + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + return provider.version + ? `${providerName} updated: ${formatVersion(provider.version)}` + : `${providerName} updated`; +} + +function getProviderUpdatedDescription(providerCount: number): string { + return providerCount === 1 + ? "New sessions will use the updated provider." + : "New sessions will use the updated providers."; +} + +function getProviderFailedUpdateTitle( + provider: Pick, +): string { + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + const attemptedVersion = provider.versionAdvisory?.latestVersion; + return attemptedVersion + ? `${providerName} ${formatVersion(attemptedVersion)} update failed` + : `${providerName} update failed`; +} + +export function isProviderUpdateCandidate( + provider: ServerProvider, +): provider is ProviderUpdateCandidate { + return ( + provider.enabled && + provider.versionAdvisory?.status === "behind_latest" && + provider.versionAdvisory.latestVersion !== null + ); +} + +export function isProviderUpdateActive(provider: Pick): boolean { + return provider.updateState?.status === "queued" || provider.updateState?.status === "running"; +} + +export function collectProviderUpdateCandidates( + providers: ReadonlyArray, +): ProviderUpdateCandidate[] { + return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate)); +} + +export function hasOneClickUpdateProviderCandidate( + candidate: ProviderUpdateCandidate, + providers: ReadonlyArray, +): boolean { + if ( + candidate.versionAdvisory.canUpdate !== true || + candidate.versionAdvisory.updateCommand === null + ) { + return false; + } + + const driverProviders = providers.filter((provider) => provider.driver === candidate.driver); + if (driverProviders.length === 0) { + return false; + } + + const updateCommands = new Set(); + for (const provider of driverProviders) { + if (!isProviderUpdateCandidate(provider)) { + continue; + } + const advisory = provider.versionAdvisory; + if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) { + return false; + } + updateCommands.add(advisory.updateCommand); + } + + return updateCommands.size === 1; +} + +export function canOneClickUpdateProviderCandidate( + candidate: ProviderUpdateCandidate, + providers: ReadonlyArray, +): boolean { + return ( + !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers) + ); +} + +export function providerUpdateNotificationKey( + providers: ReadonlyArray, +): string | null { + const parts = dedupeProvidersByDriver(providers) + .map((provider) => { + const advisory = provider.versionAdvisory; + return [provider.driver, advisory.latestVersion].join(":"); + }) + .toSorted(); + + return parts.length > 0 ? parts.join("|") : null; +} + +export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): string { + return providerUpdateNotificationKey([provider])!; +} + +export function formatProviderList(providers: ReadonlyArray>) { + const names = providers.map( + (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, + ); + if (names.length <= 2) { + return names.join(" and "); + } + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; +} + +export function getProviderUpdateInitialToastView(input: { + readonly updateProviders: ReadonlyArray; + readonly oneClickProviders: ReadonlyArray; +}): ProviderUpdateToastView { + return { + phase: "initial", + type: "warning", + title: getProviderUpdateInitialToastTitle(input.updateProviders), + description: + input.oneClickProviders.length > 0 + ? "Install the update now or review provider settings." + : `${formatProviderList(input.updateProviders)} can be updated from provider settings.`, + }; +} + +export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { + return { + phase: "running", + type: "loading", + title: providerCount === 1 ? "Updating provider" : "Updating providers", + description: "Running provider update command.", + }; +} + +export function getProviderUpdateRejectedToastView( + providerCount: number, + message: string, +): ProviderUpdateToastView { + return { + phase: "failed", + type: "error", + title: providerCount === 1 ? "Provider update failed" : "Provider updates failed", + description: message, + }; +} + +export function getProviderUpdateProgressToastView(input: { + readonly providers: ReadonlyArray; + readonly providerCount: number; +}): ProviderUpdateToastView { + const providers = dedupeProvidersByDriver(input.providers); + const failedProviders = providers.filter((provider) => provider.updateState?.status === "failed"); + if (failedProviders.length > 0) { + return { + phase: "failed", + type: "error", + title: failedProviders.length === 1 ? "Provider update failed" : "Provider updates failed", + description: getFailedProviderUpdateDescription(failedProviders), + }; + } + + const unchangedProviders = providers.filter( + (provider) => provider.updateState?.status === "unchanged", + ); + if (unchangedProviders.length > 0) { + return { + phase: "unchanged", + type: "warning", + title: + unchangedProviders.length === 1 + ? "Provider still needs an update" + : "Providers still need updates", + description: `${formatProviderList(unchangedProviders)} ${ + unchangedProviders.length === 1 ? "still appears" : "still appear" + } outdated. Check provider settings for details.`, + }; + } + + if (providers.some(isProviderUpdateActive)) { + return getProviderUpdateRunningToastView(input.providerCount); + } + + const hasCompleteProviderSnapshots = providers.length >= input.providerCount; + const allProvidersUpdated = + hasCompleteProviderSnapshots && + providers.every( + (provider) => + provider.updateState?.status === "succeeded" || !isProviderUpdateCandidate(provider), + ); + if (allProvidersUpdated) { + return { + phase: "succeeded", + type: "success", + title: input.providerCount === 1 ? "Provider updated" : "Provider updates finished", + description: getProviderUpdatedDescription(input.providerCount), + dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, + }; + } + + return getProviderUpdateRunningToastView(input.providerCount); +} + +export function getSingleProviderUpdateProgressToastView( + provider: ServerProvider, +): ProviderUpdateToastView { + const view = getProviderUpdateProgressToastView({ + providers: [provider], + providerCount: 1, + }); + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + + switch (view.phase) { + case "running": + return { + ...view, + title: `Updating ${providerName}`, + }; + case "failed": + return { + ...view, + title: getProviderFailedUpdateTitle(provider), + }; + case "unchanged": + return { + ...view, + title: `${providerName} still needs an update`, + }; + case "succeeded": + return { + ...view, + title: getProviderUpdatedTitle(provider), + }; + default: + return view; + } +} + +export function collectUpdatedProviderSnapshots(input: { + readonly results: ReadonlyArray< + PromiseSettledResult<{ readonly providers: ReadonlyArray }> + >; + readonly providerInstanceIds: ReadonlySet; +}): ServerProvider[] { + const matchedProviders: ServerProvider[] = []; + + for (const result of input.results) { + if (result.status !== "fulfilled") { + continue; + } + for (const provider of result.value.providers) { + if (input.providerInstanceIds.has(provider.instanceId)) { + matchedProviders.push(provider); + } + } + } + + return dedupeProvidersByInstanceId(matchedProviders); +} + +export function firstRejectedProviderUpdateMessage( + results: ReadonlyArray>, +): string | null { + const rejected = results.find((result) => result.status === "rejected"); + if (!rejected) { + return null; + } + return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; +} + +function getUpdateFinishedAt(provider: ServerProvider): string | null { + return provider.updateState?.finishedAt ?? null; +} + +function isRecentTerminalProvider( + provider: ServerProvider, + visibleAfterIso: string | undefined, +): boolean { + const status = provider.updateState?.status; + if (status !== "failed" && status !== "unchanged" && status !== "succeeded") { + return false; + } + if (visibleAfterIso === undefined) { + return true; + } + const finishedAt = getUpdateFinishedAt(provider); + return finishedAt !== null && finishedAt >= visibleAfterIso; +} + +function latestFinishedAtForProviders(providers: ReadonlyArray): string | null { + return providers.reduce((latest, provider) => { + const finishedAt = getUpdateFinishedAt(provider); + if (finishedAt === null) { + return latest; + } + return latest === null || finishedAt > latest ? finishedAt : latest; + }, null); +} + +export function getProviderUpdateSidebarPillView( + providers: ReadonlyArray, + options?: ProviderUpdateSidebarPillOptions, +): ProviderUpdateSidebarPillView | null { + const dedupedProviders = dedupeProvidersByDriver(providers); + const activeProviders = dedupedProviders.filter(isProviderUpdateActive); + if (activeProviders.length > 0) { + const activeProvider = activeProviders[0]!; + const activeProviderName = + PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver; + return { + key: `loading:${activeProviders + .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`) + .toSorted() + .join("|")}`, + tone: "loading", + title: + activeProviders.length === 1 + ? `Updating ${activeProviderName}` + : `Updating ${activeProviders.length} providers`, + description: + activeProviders.length === 1 + ? `${formatProviderList(activeProviders)} update in progress.` + : `${formatProviderList(activeProviders)} updates are in progress.`, + }; + } + + const recentTerminalProviders = dedupedProviders.filter((provider) => + isRecentTerminalProvider(provider, options?.visibleAfterIso), + ); + const terminalCandidates: ProviderUpdateSidebarPillView[] = []; + + const failedProviders = recentTerminalProviders.filter( + (provider) => provider.updateState?.status === "failed", + ); + if (failedProviders.length > 0) { + const failedProvider = failedProviders[0]!; + terminalCandidates.push({ + key: `failed:${failedProviders + .map( + (provider) => + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, + ) + .toSorted() + .join("|")}`, + tone: "error", + title: + failedProviders.length === 1 + ? getProviderFailedUpdateTitle(failedProvider) + : `${failedProviders.length} provider updates failed`, + description: getFailedProviderUpdateDescription(failedProviders), + dismissible: true, + }); + } + + const unchangedProviders = recentTerminalProviders.filter( + (provider) => provider.updateState?.status === "unchanged", + ); + if (unchangedProviders.length > 0) { + const unchangedProvider = unchangedProviders[0]!; + const unchangedProviderName = + PROVIDER_DISPLAY_NAMES[unchangedProvider.driver] ?? unchangedProvider.driver; + terminalCandidates.push({ + key: `unchanged:${unchangedProviders + .map( + (provider) => + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, + ) + .toSorted() + .join("|")}`, + tone: "warning", + title: + unchangedProviders.length === 1 + ? `${unchangedProviderName} still needs an update` + : `${unchangedProviders.length} providers still need updates`, + description: `${formatProviderList(unchangedProviders)} ${ + unchangedProviders.length === 1 ? "still appears" : "still appear" + } outdated. Review provider settings for details.`, + dismissible: true, + }); + } + + const succeededProviders = recentTerminalProviders.filter( + (provider) => provider.updateState?.status === "succeeded", + ); + if (succeededProviders.length > 0) { + const succeededProvider = succeededProviders[0]!; + terminalCandidates.push({ + key: `succeeded:${succeededProviders + .map( + (provider) => + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, + ) + .toSorted() + .join("|")}`, + tone: "success", + title: + succeededProviders.length === 1 + ? getProviderUpdatedTitle(succeededProvider) + : `${succeededProviders.length} providers updated`, + description: getProviderUpdatedDescription(succeededProviders.length), + dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, + }); + } + + return ( + terminalCandidates + .toSorted((left, right) => { + const leftProviders = + left.tone === "error" + ? failedProviders + : left.tone === "warning" + ? unchangedProviders + : succeededProviders; + const rightProviders = + right.tone === "error" + ? failedProviders + : right.tone === "warning" + ? unchangedProviders + : succeededProviders; + const leftFinishedAt = latestFinishedAtForProviders(leftProviders) ?? ""; + const rightFinishedAt = latestFinishedAtForProviders(rightProviders) ?? ""; + return rightFinishedAt.localeCompare(leftFinishedAt); + }) + .find((candidate) => !options?.dismissedKeys?.has(candidate.key)) ?? null + ); +} + +function getProviderUpdateInitialToastTitle( + providers: ReadonlyArray, +): string { + if (providers.length === 1) { + const provider = providers[0]!; + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + return `Update Available: ${providerName} ${formatVersion(provider.versionAdvisory.latestVersion)}`; + } + return `Updates Available: ${providers.length} providers`; +} + +function getFailedProviderUpdateDescription(providers: ReadonlyArray): string { + if (providers.length === 1) { + const provider = providers[0]!; + if (provider.updateState?.message) { + return provider.updateState.message; + } + } + return `${formatProviderList(providers)} failed to update. Check provider settings for details.`; +} diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx new file mode 100644 index 00000000000..69cd83bf8dc --- /dev/null +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -0,0 +1,300 @@ +import { useNavigate } from "@tanstack/react-router"; +import { DownloadIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; + +import { ensureLocalApi } from "../localApi"; +import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; +import { useServerProviders } from "../rpc/serverState"; +import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + collectUpdatedProviderSnapshots, + firstRejectedProviderUpdateMessage, + getProviderUpdateInitialToastView, + getProviderUpdateProgressToastView, + getProviderUpdateRejectedToastView, + getProviderUpdateRunningToastView, + providerUpdateNotificationKey, + type ProviderUpdateToastView, +} from "./ProviderUpdateLaunchNotification.logic"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +const seenProviderUpdateNotificationKeys = new Set(); +type ProviderUpdateToastId = ReturnType; + +type ActiveProviderUpdateToast = + | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } + | { + readonly kind: "update"; + readonly key: string; + readonly toastId: ProviderUpdateToastId; + readonly providerInstanceIds: ReadonlySet; + readonly providerCount: number; + }; + +function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) { + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider]; + + if (!ProviderIcon) { + return ( + + + ); + } + + return ( + + + ); +} + +function updateProviderUpdateToast(input: { + readonly toastId: ProviderUpdateToastId; + readonly view: ProviderUpdateToastView; + readonly openSettings: () => void; +}) { + if (input.view.type === "loading" || input.view.type === "success") { + toastManager.update(input.toastId, { + type: input.view.type, + title: input.view.title, + description: input.view.description, + timeout: 0, + data: { + hideCopyButton: true, + ...(input.view.dismissAfterVisibleMs !== undefined + ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs } + : {}), + }, + }); + return; + } + + toastManager.update( + input.toastId, + stackedThreadToast({ + type: input.view.type, + title: input.view.title, + description: input.view.description, + timeout: 0, + actionProps: { + children: "Settings", + onClick: input.openSettings, + }, + actionVariant: "outline", + data: { + hideCopyButton: true, + }, + }), + ); +} + +function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { + return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; +} + +export function ProviderUpdateLaunchNotification() { + const navigate = useNavigate(); + const providers = useServerProviders(); + const activeToastRef = useRef(null); + const { dismissedNotificationKeys, dismissNotificationKey } = + useDismissedProviderUpdateNotificationKeys(); + + const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); + const notificationKey = useMemo( + () => providerUpdateNotificationKey(updateProviders), + [updateProviders], + ); + const oneClickProviders = useMemo( + () => + updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)), + [providers, updateProviders], + ); + + const openProviderSettings = useCallback( + (toastId?: ProviderUpdateToastId) => { + const activeToast = activeToastRef.current; + if (toastId !== undefined) { + toastManager.close(toastId); + } else if (activeToast) { + toastManager.close(activeToast.toastId); + } + if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { + activeToastRef.current = null; + } + void navigate({ to: "/settings/providers" }); + }, + [navigate], + ); + + useEffect(() => { + const activeToast = activeToastRef.current; + if (activeToast?.kind !== "update") { + return; + } + + const activeProviders = providers.filter((provider) => + activeToast.providerInstanceIds.has(provider.instanceId), + ); + const view = getProviderUpdateProgressToastView({ + providers: activeProviders, + providerCount: activeToast.providerCount, + }); + updateProviderUpdateToast({ + toastId: activeToast.toastId, + view, + openSettings: () => openProviderSettings(activeToast.toastId), + }); + + if (isTerminalProviderUpdateToastView(view)) { + activeToastRef.current = null; + } + }, [providers, openProviderSettings]); + + useEffect(() => { + const activeToast = activeToastRef.current; + if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) { + toastManager.close(activeToast.toastId); + activeToastRef.current = null; + } + + if ( + !notificationKey || + dismissedNotificationKeys.has(notificationKey) || + seenProviderUpdateNotificationKeys.has(notificationKey) || + activeToastRef.current + ) { + return; + } + + seenProviderUpdateNotificationKeys.add(notificationKey); + + const initialView = getProviderUpdateInitialToastView({ updateProviders, oneClickProviders }); + + let toastId!: ProviderUpdateToastId; + let updateStarted = false; + const openSettings = () => openProviderSettings(toastId); + const dismissPrompt = () => { + dismissNotificationKey(notificationKey); + }; + + const runUpdates = () => { + if (updateStarted || oneClickProviders.length === 0) { + return; + } + updateStarted = true; + + const providerCount = oneClickProviders.length; + const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); + activeToastRef.current = { + kind: "update", + key: notificationKey, + toastId, + providerInstanceIds, + providerCount, + }; + + updateProviderUpdateToast({ + toastId, + view: getProviderUpdateRunningToastView(providerCount), + openSettings, + }); + + void Promise.allSettled( + oneClickProviders.map(async (provider) => + ensureLocalApi().server.updateProvider({ + provider: provider.driver, + instanceId: provider.instanceId, + }), + ), + ).then((results) => { + const activeUpdateToast = activeToastRef.current; + if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { + return; + } + + const rejectedMessage = firstRejectedProviderUpdateMessage(results); + if (rejectedMessage) { + updateProviderUpdateToast({ + toastId, + view: getProviderUpdateRejectedToastView(providerCount, rejectedMessage), + openSettings, + }); + activeToastRef.current = null; + return; + } + + const updatedProviderSnapshots = collectUpdatedProviderSnapshots({ + results, + providerInstanceIds, + }); + const view = getProviderUpdateProgressToastView({ + providers: updatedProviderSnapshots, + providerCount, + }); + updateProviderUpdateToast({ + toastId, + view, + openSettings, + }); + + if (isTerminalProviderUpdateToastView(view)) { + activeToastRef.current = null; + } + }); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: initialView.type, + title: initialView.title, + description: initialView.description, + timeout: 0, + actionProps: + oneClickProviders.length > 0 + ? { + children: "Update", + onClick: runUpdates, + } + : { + children: "Settings", + onClick: openSettings, + }, + actionVariant: oneClickProviders.length > 0 ? "default" : "outline", + data: { + leadingIcon: + updateProviders.length === 1 ? ( + + ) : undefined, + hideCopyButton: true, + onClose: dismissPrompt, + ...(oneClickProviders.length > 0 + ? { + secondaryActionProps: { + children: "Settings", + onClick: openSettings, + }, + secondaryActionVariant: "outline" as const, + } + : {}), + }, + }), + ); + activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; + }, [ + dismissNotificationKey, + dismissedNotificationKeys, + notificationKey, + oneClickProviders, + openProviderSettings, + updateProviders, + ]); + + return null; +} diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 6c134f95a01..7c241afdedb 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -7,8 +7,10 @@ import { gitPreparePullRequestThreadMutationOptions, gitResolvePullRequestQueryOptions, } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; +import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { Button } from "./ui/button"; import { Dialog, @@ -51,6 +53,13 @@ export function PullRequestThreadDialog({ { wait: 450 }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); + const { data: gitStatus = null } = useGitStatus({ environmentId, cwd }); + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(gitStatus?.sourceControlProvider), + [gitStatus?.sourceControlProvider], + ); + const terminology = sourceControlPresentation.terminology; + const SourceControlIcon = sourceControlPresentation.Icon; useEffect(() => { if (!open) return; @@ -161,20 +170,20 @@ export function PullRequestThreadDialog({ const validationMessage = !referenceDirty ? null : reference.trim().length === 0 - ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123." + ? `Paste a ${terminology.singular} URL, checkout command, or enter 123 / #123.` : parsedReference === null - ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123." + ? `Use a ${terminology.singular} URL, checkout command, 123, or #123.` : null; const errorMessage = validationMessage ?? (resolvedPullRequest === null && resolvePullRequestQuery.isError ? resolvePullRequestQuery.error instanceof Error ? resolvePullRequestQuery.error.message - : "Failed to resolve pull request." + : `Failed to resolve ${terminology.singular}.` : preparePullRequestThreadMutation.error instanceof Error ? preparePullRequestThreadMutation.error.message : preparePullRequestThreadMutation.error - ? "Failed to prepare pull request thread." + ? `Failed to prepare ${terminology.singular} thread.` : null); return ( @@ -188,18 +197,23 @@ export function PullRequestThreadDialog({ > - Checkout Pull Request + + + Checkout {terminology.singular} + - Resolve a GitHub pull request, then create the draft thread in the main repo or in a - dedicated worktree. + Resolve a {sourceControlPresentation.providerName} {terminology.singular}, then create + the draft thread in the main repo or in a dedicated worktree.
    @@ -661,7 +616,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} -
    +
    {isConfirmingArchive ? ( + + + + + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
    + Grouping rule + +
    +

    + {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

    +
    + + + + +
    +
    ); }); @@ -1839,14 +2261,36 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, + projectGroupingMode, + threadPreviewCount, onProjectSortOrderChange, onThreadSortOrderChange, + onProjectGroupingModeChange, + onThreadPreviewCountChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; + threadPreviewCount: SidebarThreadPreviewCount; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; + onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; }) { + const handleThreadPreviewCountChange = useCallback( + (nextValue: number | null) => { + if (nextValue === null) { + return; + } + + const clampedValue = clampSidebarThreadPreviewCount(nextValue); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, threadPreviewCount], + ); + return ( @@ -1857,9 +2301,9 @@ function ProjectSortMenu({ > - Sort projects + Sidebar options - +
    Sort projects @@ -1898,6 +2342,66 @@ function ProjectSortMenu({ ))} + +
    + Visible threads +
    +
    + + + + { + event.stopPropagation(); + }} + /> + + + +
    +
    + + +
    + Group projects +
    + { + if (value === "repository" || value === "repository_path" || value === "separate") { + onProjectGroupingModeChange(value); + } + }} + > + {( + Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< + [SidebarProjectGroupingMode, string] + > + ).map(([value, label]) => ( + + {label} + + ))} + +
    ); @@ -1974,7 +2478,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ ); return isElectron ? ( - + {wordmark} ) : ( @@ -1984,12 +2488,17 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); const handleSettingsClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } void navigate({ to: "/settings" }); - }, [navigate]); + }, [isMobile, navigate, setOpenMobile]); return ( + @@ -2015,21 +2524,10 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; + threadPreviewCount: SidebarThreadPreviewCount; updateSettings: ReturnType["updateSettings"]; - shouldShowProjectPathEntry: boolean; - handleStartAddProject: () => void; - isElectron: boolean; - isPickingFolder: boolean; - isAddingProject: boolean; - handlePickFolder: () => Promise; - addProjectInputRef: React.RefObject; - addProjectError: string | null; - newCwd: string; - setNewCwd: React.Dispatch>; - setAddProjectError: React.Dispatch>; - handleAddProject: () => void; - setAddingProject: React.Dispatch>; - canAddProject: boolean; + openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; @@ -2067,21 +2565,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + projectGroupingMode, + threadPreviewCount, updateSettings, - shouldShowProjectPathEntry, - handleStartAddProject, - isElectron, - isPickingFolder, - isAddingProject, - handlePickFolder, - addProjectInputRef, - addProjectError, - newCwd, - setNewCwd, - setAddProjectError, - handleAddProject, - setAddingProject, - canAddProject, + openAddProject, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, @@ -2120,26 +2607,18 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); - const handleAddProjectInputChange = useCallback( - (event: React.ChangeEvent) => { - setNewCwd(event.target.value); - setAddProjectError(null); + const handleProjectGroupingModeChange = useCallback( + (groupingMode: SidebarProjectGroupingMode) => { + updateSettings({ sidebarProjectGroupingMode: groupingMode }); }, - [setAddProjectError, setNewCwd], + [updateSettings], ); - const handleAddProjectInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); }, - [handleAddProject, setAddProjectError, setAddingProject], + [updateSettings], ); - const handleBrowseForFolderClick = useCallback(() => { - void handlePickFolder(); - }, [handlePickFolder]); return ( @@ -2198,76 +2677,31 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( } > - + - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - + Add project
    - {shouldShowProjectPathEntry && ( -
    - {isElectron && ( - - )} -
    - - -
    - {addProjectError && ( -

    - {addProjectError} -

    - )} -
    - )} {isManualProjectSorting ? ( )} - {projectsLength === 0 && !shouldShowProjectPathEntry && ( + {projectsLength === 0 && (
    No projects yet
    @@ -2355,7 +2789,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); - const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -2364,22 +2797,20 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); + const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); + const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); + const { isMobile, setOpenMobile } = useSidebar(); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const keybindings = useServerKeybindings(); - const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); - const [isAddingProject, setIsAddingProject] = useState(false); - const [addProjectError, setAddProjectError] = useState(null); - const addProjectInputRef = useRef(null); + const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); @@ -2388,13 +2819,11 @@ export default function Sidebar() { const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); - const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; - const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const shortcutModifiers = useShortcutModifierState(); + const modelPickerOpen = useModelPickerOpen(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); @@ -2402,7 +2831,7 @@ export default function Sidebar() { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + getId: getProjectOrderKey, }); }, [projectOrder, projects]); @@ -2410,79 +2839,36 @@ export default function Sidebar() { // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + }); + }, [orderedProjects, projectGroupingSettings]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const snapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => { + const rt = savedEnvironmentRuntimeById[environmentId]; + const saved = savedEnvironmentRegistry[environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? null; + }, + }); }, [ orderedProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -2510,18 +2896,22 @@ export default function Sidebar() { } const activeThread = sidebarThreadByKey.get(routeThreadKey); if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const existing = next.get(logicalKey); if (existing) { @@ -2531,7 +2921,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey]); + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -2541,8 +2931,9 @@ export default function Sidebar() { routeThreadRef, ).terminalOpen : false, + modelPickerOpen, }), - [routeThreadRef], + [modelPickerOpen, routeThreadRef], ); const newThreadShortcutLabelOptions = useMemo( () => ({ @@ -2557,131 +2948,6 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - const focusMostRecentThreadForProject = useCallback( - (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(projectRef.environmentId, projectRef.projectId), - ); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const latestThread = sortThreads( - (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), - sidebarThreadSortOrder, - )[0]; - if (!latestThread) return; - - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), - }); - }, - [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], - ); - - const addProjectFromInput = useCallback( - async (rawCwd: string) => { - const cwd = rawCwd.trim(); - if (!cwd || isAddingProject) return; - const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; - if (!api) return; - - setIsAddingProject(true); - const finishAddingProject = () => { - setIsAddingProject(false); - setNewCwd(""); - setAddProjectError(null); - setAddingProject(false); - }; - - const existing = projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject({ - environmentId: existing.environmentId, - projectId: existing.id, - }); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; - try { - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title, - workspaceRoot: cwd, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - createdAt: new Date().toISOString(), - }); - if (activeEnvironmentId !== null) { - await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { - envMode: defaultThreadEnvMode, - }).catch(() => undefined); - } - } catch (error) { - const description = - error instanceof Error ? error.message : "An error occurred while adding the project."; - setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } - return; - } - finishAddingProject(); - }, - [ - focusMostRecentThreadForProject, - activeEnvironmentId, - handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - defaultThreadEnvMode, - ], - ); - - const handleAddProject = () => { - void addProjectFromInput(newCwd); - }; - - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - - const handlePickFolder = async () => { - const api = readLocalApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } - if (pickedPath) { - await addProjectFromInput(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); - } - setIsPickingFolder(false); - }; - - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { @@ -2689,12 +2955,15 @@ export default function Sidebar() { clearSelection(); } setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, - [clearSelection, navigate, setSelectionAnchor], + [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], ); const projectDnDSensors = useSensors( @@ -2723,8 +2992,10 @@ export default function Sidebar() { const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey); - const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey); + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(activeMemberKeys, overMemberKeys); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], @@ -2773,7 +3044,10 @@ export default function Sidebar() { id: project.projectKey, })); const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); return { ...thread, projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, @@ -2790,6 +3064,7 @@ export default function Sidebar() { }, [ sidebarProjectSortOrder, physicalToLogicalKey, + projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, visibleThreads, @@ -2819,11 +3094,11 @@ export default function Sidebar() { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + : projectThreads.slice(0, sidebarThreadPreviewCount); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; return renderedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -2831,6 +3106,7 @@ export default function Sidebar() { }), [ sidebarThreadSortOrder, + sidebarThreadPreviewCount, expandedThreadListsByProject, projectExpandedById, routeThreadKey, @@ -2854,64 +3130,73 @@ export default function Sidebar() { () => [...threadJumpCommandByKey.keys()], [threadJumpCommandByKey], ); - const [threadJumpLabelByKey, setThreadJumpLabelByKey] = - useState>(EMPTY_THREAD_JUMP_LABELS); - const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; + const sidebarShortcutContext = useMemo( + () => ({ + terminalFocus: false, + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + modelPickerOpen, + }), + [modelPickerOpen, routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: sidebarShortcutContext.terminalOpen, + threadJumpCommandByKey, + }), + [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], + ); + const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { + platform, + context: sidebarShortcutContext, + }, + ); const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const prewarmedSidebarThreadKeys = useMemo( + () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const prewarmedSidebarThreadRefs = useMemo( + () => + prewarmedSidebarThreadKeys.flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref ? [ref] : []; + }), + [prewarmedSidebarThreadKeys], + ); useEffect(() => { - const clearThreadJumpHints = () => { - setThreadJumpLabelByKey((current) => - current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, - ); - updateThreadJumpHintsVisibility(false); + const releases = prewarmedSidebarThreadRefs.map((ref) => + retainThreadDetailSubscription(ref.environmentId, ref.threadId), + ); + + return () => { + for (const release of releases) { + release(); + } }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + }, [prewarmedSidebarThreadRefs]); + + useEffect(() => { + updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); + }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - } if (event.defaultPrevented || event.repeat) { return; @@ -2961,43 +3246,10 @@ export default function Sidebar() { navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); return () => { window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); }; }, [ getCurrentSidebarShortcutContext, @@ -3007,14 +3259,12 @@ export default function Sidebar() { platform, routeThreadKey, sidebarThreadByKey, - threadJumpCommandByKey, threadJumpThreadKeys, - updateThreadJumpHintsVisibility, ]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadCount === 0) return; + if (!useThreadSelectionStore.getState().hasSelection()) return; const target = event.target instanceof HTMLElement ? event.target : null; if (!shouldClearThreadSelectionOnMouseDown(target)) return; clearSelection(); @@ -3024,7 +3274,7 @@ export default function Sidebar() { return () => { window.removeEventListener("mousedown", onMouseDown); }; - }, [clearSelection, selectedThreadCount]); + }, [clearSelection]); useEffect(() => { if (!isElectron) return; @@ -3093,18 +3343,22 @@ export default function Sidebar() { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not download update", - description: actionError, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); return; } @@ -3120,18 +3374,22 @@ export default function Sidebar() { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; - toastManager.add({ - type: "error", - title: "Could not install update", - description: actionError, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); }) .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); }); } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); @@ -3170,21 +3428,10 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + projectGroupingMode={sidebarProjectGroupingMode} + threadPreviewCount={sidebarThreadPreviewCount} updateSettings={updateSettings} - shouldShowProjectPathEntry={shouldShowProjectPathEntry} - handleStartAddProject={handleStartAddProject} - isElectron={isElectron} - isPickingFolder={isPickingFolder} - isAddingProject={isAddingProject} - handlePickFolder={handlePickFolder} - addProjectInputRef={addProjectInputRef} - addProjectError={addProjectError} - newCwd={newCwd} - setNewCwd={setNewCwd} - setAddProjectError={setAddProjectError} - handleAddProject={handleAddProject} - setAddingProject={setAddingProject} - canAddProject={canAddProject} + openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} projectDnDSensors={projectDnDSensors} projectCollisionDetection={projectCollisionDetection} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx new file mode 100644 index 00000000000..5ed0c0e4c40 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -0,0 +1,250 @@ +import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import type { VcsStatusResult } from "@t3tools/contracts"; +import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { useMemo } from "react"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { useGitStatus } from "../lib/gitStatusState"; +import { type AppState, selectProjectByRef, useStore } from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; +import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; +import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import type { SidebarThreadSummary } from "../types"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export interface PrStatusIndicator { + label: string; + colorClass: string; + tooltip: string; + url: string; +} + +export interface TerminalStatusIndicator { + label: "Terminal process running"; + colorClass: string; + pulse: boolean; +} + +export type ThreadPr = VcsStatusResult["pr"]; + +export function prStatusIndicator( + pr: ThreadPr, + provider: VcsStatusResult["sourceControlProvider"] | null | undefined, +): PrStatusIndicator | null { + if (!pr) return null; + const presentation = resolveChangeRequestPresentation(provider); + + if (pr.state === "open") { + return { + label: `${presentation.shortName} open`, + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: `${presentation.shortName} closed`, + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: `${presentation.shortName} merged`, + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, + url: pr.url, + }; + } + return null; +} + +export function ChangeRequestStatusIcon({ className }: { className?: string }) { + return ; +} + +export function resolveThreadPr( + threadBranch: string | null, + gitStatus: VcsStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + +export function terminalStatusFromRunningIds( + runningTerminalIds: string[], +): TerminalStatusIndicator | null { + if (runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + }; +} + +export function ThreadStatusLabel({ + status, + compact = false, +}: { + status: ThreadStatusPill; + compact?: boolean; +}) { + if (compact) { + return ( + + + {status.label} + + ); + } + + return ( + + + {status.label} + + ); +} + +/** + * Non-interactive leading status icons for a thread row in compact contexts + * like the command palette. Shows the change request state icon (if present) and the + * thread status dot, matching the sidebar's leading indicators. + */ +export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const lastVisitedAt = useUiStateStore( + (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + ); + const threadProjectCwd = useStore( + useMemo( + () => (state: AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + + if (!prStatus && !threadStatus) { + return null; + } + + return ( + + {prStatus ? ( + + + } + > + + + {prStatus.tooltip} + + ) : null} + {threadStatus ? : null} + + ); +} + +/** + * Non-interactive trailing status icons for a thread row in compact contexts + * like the command palette. Shows a terminal-running indicator and a remote + * environment indicator, matching the sidebar's trailing indicators. + */ +export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (state) => state.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + if (!terminalStatus && !isRemoteThread) { + return null; + } + + return ( + + {terminalStatus ? ( + + + + ) : null} + {isRemoteThread ? ( + + + } + > + + + {threadEnvironmentLabel} + + ) : null} + + ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 37e0df1cc4b..2df2e04f5c4 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -177,6 +177,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} + keybindings={[]} />, { container: host }, ); @@ -196,6 +197,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} + keybindings={[]} />, ); }, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91f4dbc1b36..6c71e5eb334 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { + type ResolvedKeybindingsConfig, type ScopedThreadRef, type TerminalEvent, type TerminalSessionSnapshot, @@ -28,7 +29,16 @@ import { resolveWrappedTerminalLinkRange, wrappedTerminalLinkRangeIntersectsBufferLine, } from "../terminal-links"; -import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; +import { + isDiffToggleShortcut, + isTerminalClearShortcut, + isTerminalCloseShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, + terminalDeleteShortcutData, + terminalNavigationShortcutData, +} from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -251,6 +261,7 @@ interface TerminalViewportProps { autoFocus: boolean; resizeEpoch: number; drawerHeight: number; + keybindings: ResolvedKeybindingsConfig; } export function TerminalViewport({ @@ -267,6 +278,7 @@ export function TerminalViewport({ autoFocus, resizeEpoch, drawerHeight, + keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -278,6 +290,7 @@ export function TerminalViewport({ const selectionActionRequestIdRef = useRef(0); const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); + const keybindingsRef = useRef(keybindings); const lastAppliedTerminalEventIdRef = useRef(0); const terminalHydratedRef = useRef(false); const handleSessionExited = useEffectEvent(() => { @@ -288,6 +301,10 @@ export function TerminalViewport({ }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + useEffect(() => { + keybindingsRef.current = keybindings; + }, [keybindings]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -399,6 +416,18 @@ export function TerminalViewport({ }; terminal.attachCustomKeyEventHandler((event) => { + const currentKeybindings = keybindingsRef.current; + const options = { context: { terminalFocus: true, terminalOpen: true } }; + if ( + isTerminalToggleShortcut(event, currentKeybindings, options) || + isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalNewShortcut(event, currentKeybindings, options) || + isTerminalCloseShortcut(event, currentKeybindings, options) || + isDiffToggleShortcut(event, currentKeybindings, options) + ) { + return false; + } + const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { event.preventDefault(); @@ -407,6 +436,14 @@ export function TerminalViewport({ return false; } + const deleteData = terminalDeleteShortcutData(event); + if (deleteData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(deleteData, "Failed to delete terminal input"); + return false; + } + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); @@ -783,6 +820,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + keybindings: ResolvedKeybindingsConfig; } interface TerminalActionButtonProps { @@ -836,6 +874,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal, onHeightChange, onAddTerminalContext, + keybindings, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -1154,6 +1193,7 @@ export default function ThreadTerminalDrawer({ autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} + keybindings={keybindings} />
    @@ -1176,6 +1216,7 @@ export default function ThreadTerminalDrawer({ autoFocus resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} + keybindings={keybindings} />
    )} diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts index aed81ab01c7..9a7e484dc20 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +++ b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts @@ -8,6 +8,7 @@ function makeStatus(overrides: Partial = {}): WsConnectionSt attemptCount: 0, closeCode: null, closeReason: null, + connectionLabel: null, connectedAt: null, disconnectedAt: null, hasConnected: false, diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 7b350e98496..b54bd865c8b 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -10,7 +10,7 @@ import { useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; -import { toastManager } from "./ui/toast"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { getPrimaryEnvironmentConnection } from "../environments/runtime"; const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; @@ -53,8 +53,16 @@ function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } -function buildReconnectTitle(_status: WsConnectionStatus): string { - return "Disconnected from T3 Server"; +function getConnectionDisplayName(status: WsConnectionStatus): string { + return status.connectionLabel?.trim() || "T3 Server"; +} + +function buildReconnectTitle(status: WsConnectionStatus): string { + return `Disconnected from ${getConnectionDisplayName(status)}`; +} + +function buildRecoveredTitle(status: WsConnectionStatus): string { + return `Reconnected to ${getConnectionDisplayName(status)}`; } function describeRecoveredToast( @@ -75,13 +83,34 @@ function describeRecoveredToast( return "Connection restored."; } -function describeSlowRpcAckToast(requests: ReadonlyArray): ReactNode { +function describeSlowRpcAckToast(requests: ReadonlyArray): string { const count = requests.length; const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } +function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { + return ( +
      + {requests.map((req) => ( +
    • +
      {req.tag}
      +
      + {req.requestId} +
      +
      + Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} +
      +
    • + ))} +
    + ); +} + export function shouldAutoReconnect( status: WsConnectionStatus, trigger: WsAutoReconnectTrigger, @@ -138,15 +167,18 @@ export function WebSocketConnectionCoordinator() { console.warn("Automatic WebSocket reconnect failed", { error }); return; } - toastManager.add({ - type: "error", - title: "Reconnect failed", - description: error instanceof Error ? error.message : "Unable to restart the WebSocket.", - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Reconnect failed", + description: + error instanceof Error ? error.message : "Unable to restart the WebSocket.", + data: { + dismissAfterVisibleMs: 8_000, + hideCopyButton: true, + }, + }), + ); }); }); const syncBrowserOnlineStatus = useEffectEvent(() => { @@ -253,45 +285,45 @@ export function WebSocketConnectionCoordinator() { if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { const toastPayload = shouldShowOfflineToast - ? { - description: describeOfflineToast(), - timeout: 0, - title: "Offline", - type: "warning" as const, + ? stackedThreadToast({ data: { hideCopyButton: true, }, - } + description: describeOfflineToast(), + timeout: 0, + title: "Offline", + type: "warning", + }) : shouldShowExhaustedToast - ? { + ? stackedThreadToast({ actionProps: { children: "Retry", onClick: triggerManualReconnect, }, - description: describeExhaustedToast(), - timeout: 0, - title: "Disconnected from T3 Server", - type: "error" as const, data: { hideCopyButton: true, }, - } - : { + description: describeExhaustedToast(), + timeout: 0, + title: buildReconnectTitle(status), + type: "error", + }) + : stackedThreadToast({ actionProps: { children: "Retry now", onClick: triggerManualReconnect, }, + data: { + hideCopyButton: true, + }, description: status.nextRetryAt === null ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, timeout: 0, title: buildReconnectTitle(status), - type: "loading" as const, - data: { - hideCopyButton: true, - }, - }; + type: "loading", + }); if (toastIdRef.current) { toastManager.update(toastIdRef.current, toastPayload); @@ -310,7 +342,7 @@ export function WebSocketConnectionCoordinator() { ) { const successToast = { description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), - title: "Reconnected to T3 Server", + title: buildRecoveredTitle(status), type: "success" as const, timeout: 0, data: { @@ -369,6 +401,11 @@ export function SlowRpcAckToastCoordinator() { } const nextToast = { + data: { + expandableContent: , + expandableDescriptionTrigger: true, + expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, + }, description: describeSlowRpcAckToast(slowRequests), timeout: 0, title: "Some requests are slow", diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index f583af72ec4..65e9c6dd8eb 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts"; import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; +import { addSavedEnvironment } from "../../environments/runtime"; import { peekPairingTokenFromUrl, stripPairingTokenFromUrl, submitServerAuthCredential, } from "../../environments/primary"; +import { readHostedPairingRequest } from "../../hostedPairing"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -159,6 +161,127 @@ export function PairingRouteSurface({ ); } +export function HostedPairingRouteSurface() { + const hostedPairingRequestRef = useRef(readHostedPairingRequest()); + const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => + hostedPairingRequestRef.current ? "pairing" : "error", + ); + const [message, setMessage] = useState(() => + hostedPairingRequestRef.current + ? "Connecting to this backend." + : "This pairing link is missing its backend host or token.", + ); + const [canRetry, setCanRetry] = useState(false); + const submitAttemptedRef = useRef(false); + const tokenSubmittedRef = useRef(false); + + const submitHostedPairingRequest = useCallback(async () => { + const request = hostedPairingRequestRef.current; + + if (!request) { + setStatus("error"); + setMessage("This pairing link is missing its backend host or token."); + setCanRetry(false); + return; + } + + if (tokenSubmittedRef.current) { + setStatus("error"); + setMessage("This one-time pairing token was already submitted. Request a new pairing link."); + setCanRetry(false); + return; + } + + setStatus("pairing"); + setMessage("Connecting to this backend."); + setCanRetry(false); + tokenSubmittedRef.current = true; + + try { + const record = await addSavedEnvironment({ + label: request.label, + host: request.host, + pairingCode: request.token, + }); + setStatus("paired"); + setMessage(`${record.label} is saved in this browser.`); + } catch (error) { + tokenSubmittedRef.current = false; + setStatus("error"); + setCanRetry(true); + setMessage( + `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, + ); + } + }, []); + + useEffect(() => { + if (submitAttemptedRef.current) { + return; + } + submitAttemptedRef.current = true; + + stripPairingTokenFromUrl(); + void submitHostedPairingRequest(); + }, [submitHostedPairingRequest]); + + const request = hostedPairingRequestRef.current; + + return ( +
    +
    +
    +
    +
    +
    + +
    +

    + {APP_DISPLAY_NAME} +

    +

    + {status === "paired" + ? "Backend paired" + : status === "error" + ? "Pairing failed" + : "Pairing backend"} +

    +

    {message}

    + + {request ? ( +
    + Host: {request.host} +
    + ) : null} + + {status === "error" ? ( +
    + Verify the backend is reachable from this browser, supports CORS for hosted clients, and + is served over HTTPS when opening this page from HTTPS. +
    + ) : null} + +
    + {status === "pairing" ? ( + + ) : canRetry ? ( + + ) : null} + {status === "paired" ? ( + + ) : null} +
    +
    +
    + ); +} + function errorMessageFromUnknown(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cabac8272b0..96fc34c1013 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5,7 +5,7 @@ import type { ProjectEntry, ProviderApprovalDecision, ProviderInteractionMode, - ProviderKind, + ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, ServerProvider, @@ -13,12 +13,13 @@ import type { TurnId, } from "@t3tools/contracts"; import { + ProviderDriverKind, + ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { - forwardRef, memo, useCallback, useEffect, @@ -59,7 +60,7 @@ import { shouldUseCompactComposerFooter, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; +import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -73,7 +74,7 @@ import { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, -} from "./composerProviderRegistry"; +} from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../vscode-icons"; @@ -94,7 +95,14 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { resolveSelectableProvider, getProviderModels } from "../../providerModels"; +import { getProviderInteractionModeToggle } from "../../providerModels"; +import { + deriveProviderInstanceEntries, + resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, + type ProviderInstanceEntry, +} from "../../providerInstances"; +import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; @@ -102,6 +110,7 @@ import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -129,6 +138,13 @@ const runtimeModeConfig: Record< const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; +const COMPOSER_FLOATING_LAYER_SELECTOR = [ + '[data-slot="popover-popup"]', + '[data-slot="menu-popup"]', + '[data-slot="select-popup"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', +].join(","); const extendReplacementRangeForTrailingSpace = ( text: string, @@ -158,10 +174,16 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); +function isInsideComposerFloatingLayer(element: Element): boolean { + return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; +} + const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; showPlanToggle: boolean; + planSidebarLabel: string; planSidebarOpen: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; @@ -174,25 +196,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop <> - + {props.showInteractionModeToggle ? ( + <> + - + + + ) : null} setPassword(event.target.value)} + /> +
    + {responseError ? ( +

    {responseError}

    + ) : ( +

    + Use SSH keys to avoid repeated password prompts on new SSH sessions. +

    + )} + + + + + + + + + ); +} diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 84bde530486..454ecdfe0e9 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -17,6 +17,7 @@ import { const baseState: DesktopUpdateState = { enabled: true, status: "idle", + channel: "latest", currentVersion: "1.0.0", hostArch: "x64", appArch: "x64", diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx new file mode 100644 index 00000000000..affa35ff260 --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -0,0 +1,491 @@ +"use client"; + +import { CheckIcon } from "lucide-react"; +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ProviderInstanceId, + ProviderDriverKind, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; + +import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { normalizeProviderAccentColor } from "../../providerInstances"; +import { Button } from "../ui/button"; +import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Badge } from "../ui/badge"; +import { Input } from "../ui/input"; +import { RadioGroup } from "../ui/radio-group"; +import { toastManager } from "../ui/toast"; +import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; +import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; +import { AnimatedHeight } from "../AnimatedHeight"; + +const PROVIDER_ACCENT_SWATCHES = [ + "#2563eb", + "#16a34a", + "#ea580c", + "#dc2626", + "#7c3aed", + "#0891b2", +] as const; + +/** + * Normalize a user-provided label into a slug suffix for the instance id. + * The full id is formed by prefixing the driver slug — e.g. label "Work" on + * driver "codex" becomes `codex_work`. Output is trimmed to 48 chars so the + * final composed id stays under the 64-char slug cap enforced by + * `ProviderInstanceId` in `@t3tools/contracts`. + */ +function slugifyLabel(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); +} + +function deriveInstanceId(driver: ProviderDriverKind, label: string): string { + const slug = slugifyLabel(label); + return slug ? `${driver}_${slug}` : ""; +} + +const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); +const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; +const EMPTY_CONFIG_DRAFT: Record = {}; +interface ComingSoonDriverOption { + readonly value: ProviderDriverKind; + readonly label: string; + readonly icon: Icon; +} + +const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ + { + value: ProviderDriverKind.make("githubCopilot"), + label: "Github Copilot", + icon: GithubCopilotIcon, + }, + { + value: ProviderDriverKind.make("gemini"), + label: "Gemini", + icon: Gemini, + }, + { + value: ProviderDriverKind.make("acpRegistry"), + label: "ACP Registry", + icon: ACPRegistryIcon, + }, + { + value: ProviderDriverKind.make("piAgent"), + label: "Pi Agent", + icon: PiAgentIcon, + }, +]; + +/** + * Validate an instance id against the same slug rules the server applies in + * `ProviderInstanceId` (see `packages/contracts/src/providerInstance.ts`). + * Returns a user-facing error string, or `null` if valid. + */ +function validateInstanceId(id: string, existing: ReadonlySet): string | null { + if (id.length === 0) return "Instance ID is required."; + if (id.length > 64) return "Instance ID must be 64 characters or fewer."; + if (!INSTANCE_ID_PATTERN.test(id)) { + return "Instance ID must start with a letter and use only letters, digits, '-', or '_'."; + } + if (existing.has(id)) return `An instance named '${id}' already exists.`; + return null; +} + +interface AddProviderInstanceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { + const settings = useSettings(); + const { updateSettings } = useUpdateSettings(); + + const [wizardStep, setWizardStep] = useState(0); + const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); + const [label, setLabel] = useState(""); + const [accentColor, setAccentColor] = useState(""); + const [instanceId, setInstanceId] = useState(""); + const [instanceIdDirty, setInstanceIdDirty] = useState(false); + // Driver-specific config drafts keyed by driver so toggling between drivers + // during the same dialog session does not lose in-progress input. + const [configByDriver, setConfigByDriver] = useState>>({}); + // Errors are suppressed until the user has tried to submit once. After that + // they update live so fixing the problem clears the message in place. + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + + const existingIds = useMemo( + () => new Set(Object.keys(settings.providerInstances ?? {})), + [settings.providerInstances], + ); + + // Reset the form every time the dialog opens so each creation starts + // from a clean slate. + useEffect(() => { + if (!open) return; + setDriver(DEFAULT_DRIVER_KIND); + setLabel(""); + setAccentColor(""); + setInstanceId(""); + setWizardStep(0); + setInstanceIdDirty(false); + setConfigByDriver({}); + setHasAttemptedSubmit(false); + }, [open]); + + // Auto-derive the instance id from driver + label until the user types + // in the Instance ID field directly (after which they own its value). + useEffect(() => { + if (instanceIdDirty) return; + setInstanceId(deriveInstanceId(driver, label)); + }, [driver, label, instanceIdDirty]); + + const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; + const driverSettingsFields = useMemo( + () => deriveProviderSettingsFields(driverOption), + [driverOption], + ); + const instanceIdError = validateInstanceId(instanceId, existingIds); + const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; + const previewLabel = label.trim() || `${driverOption.label} Workspace`; + const wizardSteps = ["Driver", "Identity", "Config"] as const; + const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; + + const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; + const setConfigDraft = useCallback( + (config: Record | undefined) => { + setConfigByDriver((existing) => { + const next = { ...existing }; + if (config === undefined || Object.keys(config).length === 0) { + delete next[driver]; + } else { + next[driver] = config; + } + return next; + }); + }, + [driver], + ); + + const handleSave = useCallback(() => { + setHasAttemptedSubmit(true); + if (instanceIdError !== null) return; + + const config = configByDriver[driver] ?? {}; + const hasConfig = Object.keys(config).length > 0; + const normalizedAccentColor = normalizeProviderAccentColor(accentColor); + + const nextInstance: ProviderInstanceConfig = { + driver, + enabled: true, + ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), + ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), + ...(hasConfig ? { config } : {}), + }; + // `ProviderInstanceId.make` revalidates the slug; we've already checked + // it via `validateInstanceId`, but going through the brand constructor + // keeps the type boundary honest and guards against any future drift in + // the slug rules. + const brandedId = ProviderInstanceId.make(instanceId); + const nextMap = { + ...settings.providerInstances, + [brandedId]: nextInstance, + }; + try { + updateSettings({ providerInstances: nextMap }); + toastManager.add({ + type: "success", + title: "Provider instance added", + description: `${driverOption.label} instance '${instanceId}' was added.`, + }); + onOpenChange(false); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not add provider instance", + description: error instanceof Error ? error.message : "Update failed.", + }); + } + }, [ + driver, + driverOption, + configByDriver, + instanceId, + instanceIdError, + label, + accentColor, + onOpenChange, + settings.providerInstances, + updateSettings, + ]); + + return ( + + +
    + + Add provider instance + + Configure an additional provider instance — for example, a second Codex install + pointed at a different workspace. + +
    + {wizardSteps.map((step, index) => ( + + ))} +
    +
    + +
    + +
    + + Driver + + setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-2 gap-2.5" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + const isSelected = option.value === driver; + return ( + + + + {option.label} + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} + +
    + + + + + +
    + Accent color +
    + setAccentColor(event.target.value)} + aria-label="Provider instance accent color" + className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" + /> +
    + {PROVIDER_ACCENT_SWATCHES.map((swatch) => { + const selected = accentColor.toLowerCase() === swatch; + return ( +
    + {accentColor ? ( + + ) : null} +
    + + Optional marker shown in the picker. + +
    + + {driverSettingsFields.length > 0 ? ( +
    + +
    + ) : wizardStep === 2 ? ( +
    +

    + This driver has no required configuration. You can add the instance now. +

    +
    + ) : null} +
    +
    + + + + {wizardStep < wizardSteps.length - 1 ? ( + + ) : ( + + )} + +
    +
    +
    + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ab31fe7e173..17dc177a981 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,16 +1,28 @@ -import { PlusIcon, QrCodeIcon } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { + ChevronDownIcon, + ChevronsLeftRightEllipsisIcon, + PlusIcon, + QrCodeIcon, + RefreshCwIcon, + TerminalIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { type AuthClientSession, type AuthPairingLink, + type AdvertisedEndpoint, + type DesktopDiscoveredSshHost, + type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; -import { DateTime } from "effect"; +import * as DateTime from "effect/DateTime"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; +import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { SettingsPageContainer, SettingsRow, @@ -20,6 +32,7 @@ import { import { Input } from "../ui/input"; import { Dialog, + DialogClose, DialogFooter, DialogDescription, DialogHeader, @@ -28,6 +41,7 @@ import { DialogTitle, DialogTrigger, } from "../ui/dialog"; +import { ScrollArea } from "../ui/scroll-area"; import { AlertDialog, AlertDialogClose, @@ -41,11 +55,23 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; +import { AnimatedHeight } from "../AnimatedHeight"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; import { Textarea } from "../ui/textarea"; -import { setPairingTokenOnUrl } from "../../pairingUrl"; +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; +import { readHostedPairingRequest } from "../../hostedPairing"; import { createServerPairingCredential, fetchSessionState, @@ -63,10 +89,17 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, addSavedEnvironment, + connectDesktopSshEnvironment, + disconnectSavedEnvironment, getPrimaryEnvironmentConnection, reconnectSavedEnvironment, removeSavedEnvironment, } from "~/environments/runtime"; +import { useUiStateStore } from "~/uiStateStore"; +import { resolveServerConfigVersionMismatch } from "~/versionSkew"; +import { useServerConfig } from "~/rpc/serverState"; + +const DEFAULT_TAILSCALE_SERVE_PORT = 443; const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -160,12 +193,153 @@ function getSavedBackendStatusTooltip( : "Not connected yet."; } +function formatDesktopSshTarget(target: NonNullable): string { + const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; + return target.port ? `${authority}:${target.port}` : authority; +} + +function parseManualDesktopSshTarget(input: { + readonly host: string; + readonly username: string; + readonly port: string; +}): DesktopSshEnvironmentTarget { + const rawHost = input.host.trim(); + if (rawHost.length === 0) { + throw new Error("SSH host or alias is required."); + } + + let hostname = rawHost; + let username = input.username.trim() || null; + let port: number | null = null; + + const atIndex = hostname.lastIndexOf("@"); + if (atIndex > 0) { + const inlineUsername = hostname.slice(0, atIndex).trim(); + hostname = hostname.slice(atIndex + 1).trim(); + if (!username && inlineUsername.length > 0) { + username = inlineUsername; + } + } + + const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); + if (bracketedHostMatch) { + hostname = bracketedHostMatch[1]!.trim(); + if (bracketedHostMatch[2]) { + port = Number.parseInt(bracketedHostMatch[2], 10); + } + } else { + const colonSegments = hostname.split(":"); + if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { + hostname = colonSegments[0]!.trim(); + port = Number.parseInt(colonSegments[1]!, 10); + } + } + + const rawPort = input.port.trim(); + if (rawPort.length > 0) { + port = Number.parseInt(rawPort, 10); + } + + if (hostname.length === 0) { + throw new Error("SSH host or alias is required."); + } + + if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { + throw new Error("SSH port must be between 1 and 65535."); + } + + return { + alias: hostname, + hostname, + username, + port, + }; +} + +function parsePairingUrlFields( + input: string, +): { readonly host: string; readonly pairingCode: string } | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const urlLikeInput = + /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") + ? trimmed + : `https://${trimmed}`; + const url = new URL(urlLikeInput, window.location.origin); + const hostedPairingRequest = readHostedPairingRequest(url); + if (hostedPairingRequest) { + return { + host: hostedPairingRequest.host, + pairingCode: hostedPairingRequest.token, + }; + } + + const pairingCode = getPairingTokenFromUrl(url); + if (!pairingCode) return null; + return { + host: url.origin, + pairingCode, + }; + } catch { + return null; + } +} + +function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): { + readonly host: string; + readonly pairingCode: string; +} { + const parsedPairingUrl = parsePairingUrlFields(input.host); + if (parsedPairingUrl) return parsedPairingUrl; + + const host = input.host.trim(); + const pairingCode = input.pairingCode.trim(); + if (!host) { + throw new Error("Enter a backend host."); + } + if (!pairingCode) { + throw new Error("Enter a pairing code."); + } + return { host, pairingCode }; +} + +function formatDesktopSshConnectionError(error: unknown): string { + const fallback = "Failed to connect SSH host."; + const rawMessage = error instanceof Error ? error.message : fallback; + const withoutIpcPrefix = rawMessage.replace( + /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u, + "", + ); + const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, ""); + return withoutTaggedErrorPrefix.trim() || fallback; +} + /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; +const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; const ITEM_ROW_INNER_CLASSNAME = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; +type AccessSectionPresentation = "current" | "endpoint-rail"; + +function accessRowClassName(_presentation: AccessSectionPresentation) { + return ITEM_ROW_CLASSNAME; +} + +function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { + if (presentation === "endpoint-rail") { + return cn( + "relative border-t border-border/60 px-4 py-3 first:border-t-0 sm:px-5", + !isAvailable && "bg-muted/20", + ); + } + + return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); +} + function sortDesktopPairingLinks(links: ReadonlyArray) { return [...links].toSorted( (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), @@ -243,10 +417,66 @@ function removeDesktopClientSession( return current.filter((clientSession) => clientSession.sessionId !== sessionId); } -function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { - const url = new URL(endpointUrl); - url.pathname = "/pair"; - return setPairingTokenOnUrl(url, credential).toString(); +function selectPairingEndpoint( + endpoints: ReadonlyArray, + defaultEndpointKey?: string | null, +): AdvertisedEndpoint | null { + const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); + if (defaultEndpointKey) { + const selectedEndpoint = availableEndpoints.find( + (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, + ); + if (selectedEndpoint) { + return selectedEndpoint; + } + } + return ( + availableEndpoints.find((endpoint) => endpoint.isDefault) ?? + availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? + availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? + null + ); +} + +function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { + return endpoint.id.startsWith("tailscale-magicdns:"); +} + +function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { + if (endpoint.id.startsWith("desktop-loopback:")) { + return "desktop-core:loopback:http"; + } + if (endpoint.id.startsWith("desktop-lan:")) { + return "desktop-core:lan:http"; + } + if (endpoint.id.startsWith("tailscale-ip:")) { + return "tailscale:ip:http"; + } + if (isTailscaleHttpsEndpoint(endpoint)) { + return "tailscale:magicdns:https"; + } + + let scheme = "unknown"; + try { + scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); + } catch { + // Keep the stored preference stable even if a custom endpoint is malformed. + } + + return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; +} + +function resolveAdvertisedEndpointPairingUrl( + endpoint: AdvertisedEndpoint, + credential: string, +): string { + if (endpoint.compatibility.hostedHttpsApp === "compatible") { + return ( + resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? + resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential) + ); + } + return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); } function resolveCurrentOriginPairingUrl(credential: string): string { @@ -254,9 +484,21 @@ function resolveCurrentOriginPairingUrl(credential: string): string { return setPairingTokenOnUrl(url, credential).toString(); } +function isHostedAppPairingUrl(value: string): boolean { + try { + const url = new URL(value); + return url.pathname === "/pair" && url.searchParams.has("host"); + } catch { + return false; + } +} + type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; endpointUrl: string | null | undefined; + endpoints: ReadonlyArray; + defaultEndpointKey: string | null; + presentation?: AccessSectionPresentation; revokingPairingLinkId: string | null; onRevoke: (id: string) => void; }; @@ -264,6 +506,9 @@ type PairingLinkListRowProps = { const PairingLinkListRow = memo(function PairingLinkListRow({ pairingLink, endpointUrl, + endpoints, + defaultEndpointKey, + presentation = "current", revokingPairingLinkId, onRevoke, }: PairingLinkListRowProps) { @@ -278,53 +523,197 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ () => resolveCurrentOriginPairingUrl(pairingLink.credential), [pairingLink.credential], ); + const hostedPairingUrl = useMemo( + () => + endpointUrl != null && endpointUrl !== "" + ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential) + : null, + [endpointUrl, pairingLink.credential], + ); + const endpointPairingUrl = useMemo(() => { + const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); + return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null; + }, [defaultEndpointKey, endpoints, pairingLink.credential]); + const endpointCopyOptions = useMemo( + () => + endpoints + .filter((endpoint) => endpoint.status !== "unavailable") + .map((endpoint) => { + const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); + return { + key: endpointDefaultPreferenceKey(endpoint), + label: endpoint.label, + url, + detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL", + }; + }), + [endpoints, pairingLink.credential], + ); const shareablePairingUrl = - endpointUrl != null && endpointUrl !== "" - ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) + endpointPairingUrl ?? + (endpointUrl != null && endpointUrl !== "" + ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) : isLoopbackHostname(window.location.hostname) ? null - : currentOriginPairingUrl; - const copyValue = shareablePairingUrl ?? pairingLink.credential; + : currentOriginPairingUrl); + const revealValue = shareablePairingUrl ?? pairingLink.credential; + const isShareableHostedAppPairingUrl = + shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); const canCopyToClipboard = typeof window !== "undefined" && window.isSecureContext && navigator.clipboard?.writeText != null; - const { copyToClipboard, isCopied } = useCopyToClipboard({ - onCopy: () => { + const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ + onCopy: (kind) => { toastManager.add({ type: "success", - title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", - description: shareablePairingUrl - ? "Open it in the client you want to pair to this environment." - : "Paste it into another client with this backend's reachable host.", + title: + kind === "hosted-link" + ? "Hosted app link copied" + : kind === "link" + ? "Pairing URL copied" + : "Pairing code copied", + description: + kind === "hosted-link" + ? "Open it in the browser on the device you want to connect." + : kind === "link" + ? "Open it in the client you want to pair to this environment." + : "Paste it into another client to finish pairing.", }); }, - onError: (error) => { + onError: (error, kind) => { setIsRevealDialogOpen(true); - toastManager.add({ - type: "error", - title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", - description: canCopyToClipboard ? error.message : "Showing the full value instead.", - }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: canCopyToClipboard + ? kind === "hosted-link" + ? "Could not copy hosted app link" + : kind === "link" + ? "Could not copy pairing URL" + : "Could not copy pairing code" + : "Clipboard copy unavailable", + description: canCopyToClipboard ? error.message : "Showing the full value instead.", + }), + ); }, }); - const handleCopy = useCallback(() => { - copyToClipboard(copyValue, undefined); - }, [copyToClipboard, copyValue]); + const copyPairingValue = useCallback( + (value: string, kind: "code" | "hosted-link" | "link") => { + copyToClipboard(value, kind); + }, + [copyToClipboard], + ); + + const copyKindForUrl = useCallback( + (url: string): "hosted-link" | "link" => (isHostedAppPairingUrl(url) ? "hosted-link" : "link"), + [], + ); + + const handleCopyCode = useCallback(() => { + copyPairingValue(pairingLink.credential, "code"); + }, [copyPairingValue, pairingLink.credential]); + + const handleCopyDefaultLink = useCallback(() => { + if (!shareablePairingUrl) return; + copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl)); + }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]); const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; const primaryLabel = pairingLink.label ?? `${roleLabel} link`; + const defaultEndpointCopyOption = + endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? + endpointCopyOptions[0] ?? + null; + const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL"; + const backendEndpointCopyOptions = endpointCopyOptions.filter( + (option) => !isHostedAppPairingUrl(option.url), + ); + const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) => + isHostedAppPairingUrl(option.url), + ); + const renderEndpointMenuItems = ( + options: typeof endpointCopyOptions = endpointCopyOptions, + renderDetail = true, + ) => + options.map((option) => ( + copyPairingValue(option.url, copyKindForUrl(option.url))} + > + + {option.label} + {renderDetail ? ( + + {option.detail} + + ) : null} + + + )); + const renderPairingCodeMenuItem = (renderDetail = true) => ( + + + Copy code + {renderDetail ? ( + Token only + ) : null} + + + ); + const renderCompactEndpointGroup = ( + label: string, + options: typeof endpointCopyOptions, + includeSeparator: boolean, + ) => + options.length > 0 ? ( + <> + {includeSeparator ? : null} + + {label} + {renderEndpointMenuItems(options, false)} + + + ) : null; + const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => ( + <> + {options?.codeFirst ? ( + <> + + Pairing code + {renderPairingCodeMenuItem(false)} + + {endpointCopyOptions.length > 0 ? : null} + + ) : null} + {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)} + {renderCompactEndpointGroup( + "Hosted app link", + hostedEndpointCopyOptions, + backendEndpointCopyOptions.length > 0, + )} + {!options?.codeFirst ? ( + <> + {endpointCopyOptions.length > 0 ? : null} + + Pairing code + {renderPairingCodeMenuItem(false)} + + + ) : null} + + ); if (expiresAtMs <= nowMs) { return null; } return ( -
    +
    @@ -375,27 +764,70 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
    {canCopyToClipboard ? ( - + <> + {shareablePairingUrl ? ( + + + + + + } + > + + + + {renderGroupedCopyMenuItems()} + + + + ) : ( + + )} + ) : ( }> - {shareablePairingUrl ? "Show link" : "Show token"} + {shareablePairingUrl ? "Show link" : "Show code"} )} - {shareablePairingUrl ? "Pairing link" : "Pairing token"} + + {shareablePairingUrl + ? isShareableHostedAppPairingUrl + ? "Hosted app pairing link" + : "Pairing link" + : "Pairing code"} + {shareablePairingUrl - ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." - : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} + ? isShareableHostedAppPairingUrl + ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." + : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." + : "Clipboard copy is unavailable here. Manually copy this code into another client."}