diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 000000000000..5d790e49a061 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,528 @@ +name: Release desktop build + +# One desktop platform/arch build, called once per target from release.yml so +# each target is its own job with its own `needs`. The JS bundle (server, web +# client, Electron main) comes from the `js-bundle` artifact that build_bundle +# produced; this job only packages it, builds the native helpers, and, where +# `cli_archive` is set, the self-contained CLI archive for its platform. + +on: + workflow_call: + inputs: + label: + required: true + type: string + runner: + required: true + type: string + platform: + required: true + type: string + target: + required: true + type: string + arch: + required: true + type: string + rust_target: + required: true + type: string + resource_key: + required: true + type: string + # Whether the job also builds the self-contained CLI archive for its own + # platform/arch, on this runner, and smoke-tests it here. Every archive + # is built on hardware of its own architecture. + cli_archive: + required: false + default: false + type: boolean + version: + required: true + type: string + ref: + required: true + type: string + release_channel: + required: true + type: string + clerk_publishable_key: + required: true + type: string + clerk_jwt_template: + required: true + type: string + clerk_cli_oauth_client_id: + required: true + type: string + relay_url: + required: true + type: string + +permissions: + contents: read + +jobs: + build: + name: Build ${{ inputs.label }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ inputs.relay_url }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: ${{ inputs.platform != 'win' }} + run-install: false + + - name: Resolve Windows package cache path + if: inputs.platform == 'win' + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache Windows packages + if: inputs.platform == 'win' + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + + # pnpm checks the lockfile and policy before reusing this result. A missing + # artifact leaves the cache empty, so installation runs the checks again. + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + + - name: Install desktop dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} + key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Cache Linux capture helpers + if: inputs.platform == 'linux' + id: capture_helper_cache + uses: actions/cache@v6 + with: + path: | + native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot + native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot + key: linux-capture-helpers-${{ inputs.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.rust_target }} + + - name: Download relay client tracing config + uses: actions/download-artifact@v8 + with: + name: relay-client-tracing-config + path: ${{ runner.temp }}/relay-client-tracing + + - name: Load relay client tracing config + shell: bash + run: | + config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" + tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" + echo "::add-mask::$tracing_token" + cat "$config_path" >> "$GITHUB_ENV" + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ inputs.version }}" + + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@v8 + with: + name: js-bundle + path: apps + + # The WSL backend runs the Linux CLI archive inside the distro, so the + # Windows desktop embeds the same-arch archive the release attaches. + - name: Download Linux CLI archive for WSL + if: inputs.platform == 'win' + uses: actions/download-artifact@v8 + with: + name: cli-linux-${{ inputs.arch }} + path: wsl-runtime + + - name: Install Spectre-mitigated MSVC libs + if: inputs.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.Runtimes.${{ inputs.arch == 'arm64' && 'ARM64' || '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 + } + + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + - name: Install Linux desktop build libraries + if: inputs.platform == 'linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi + + - name: Prepare Azure Trusted Signing + if: inputs.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: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} + 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: | + args=( + --platform "${{ inputs.platform }}" + --target "${{ inputs.target }}" + --arch "${{ inputs.arch }}" + --build-version "${{ inputs.version }}" + --skip-build + --verbose + ) + + has_all() { + for value in "$@"; do + if [[ -z "$value" ]]; then + return 1 + fi + done + return 0 + } + + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then + if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then + echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 + exit 1 + fi + + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + + echo "macOS signing enabled." + args+=(--signed) + else + echo "macOS signing disabled (missing one or more Apple signing secrets)." + fi + elif [[ "${{ inputs.platform }}" == "win" ]]; then + # Embed the Linux CLI archive built by the same-arch Linux job as + # the WSL runtime. Required for a working WSL backend on Windows. + args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-${{ inputs.arch }}.tar.gz) + if has_all \ + "$AZURE_TENANT_ID" \ + "$AZURE_CLIENT_ID" \ + "$AZURE_CLIENT_SECRET" \ + "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ + "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ + "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ + "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then + echo "Windows signing enabled (Azure Trusted Signing)." + args+=(--signed) + else + echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." + fi + else + echo "Signing disabled for ${{ inputs.platform }}." + fi + + vp run dist:desktop:artifact "${args[@]}" + + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. It always + # injects into the runner's own Node: tsdown's cross-target download path + # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a + # remote host, and a cross-built macOS binary cannot be smoke-tested. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Import macOS signing certificate for the CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: inputs.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: inputs.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + 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 }} + run: | + set -euo pipefail + if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ inputs.platform }}" \ + --arch "${{ inputs.arch }}" \ + --version "${{ inputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: inputs.cli_archive + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + + - name: Upload CLI archive + if: inputs.cli_archive + uses: actions/upload-artifact@v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + + - name: Collect release assets + shell: bash + run: | + set -euo pipefail + mkdir -p release-publish + + shopt -s nullglob + patterns=( + "release/*.dmg" + "release/*.zip" + "release/*.AppImage" + "release/*.exe" + ) + # Preview builds have no publish config, so electron-builder writes + # no feed manifest for them, but it still emits blockmaps beside the + # installers. Neither belongs on a release no updater may follow. + if [[ "${{ inputs.release_channel }}" != "preview" ]]; then + patterns+=("release/*.blockmap" "release/*.yml") + fi + for pattern in "${patterns[@]}"; do + for file in $pattern; do + cp "$file" release-publish/ + done + done + + if [[ "${{ inputs.platform }}" == "mac" && "${{ inputs.arch }}" != "arm64" ]]; then + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ inputs.arch }}.yml" + done + fi + + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml") and carries no arch, so the x64 and + # arm64 jobs would upload the same name. Suffix each per-arch copy; + # the release job merges them back into one manifest per channel. + # builder-debug.yml is electron-builder's config dump, not a feed. + if [[ "${{ inputs.platform }}" == "win" ]]; then + for manifest in release-publish/*.yml; do + [[ "$manifest" == */builder-debug.yml ]] && continue + mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml" + done + fi + + - name: Collect resource monitor + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + source_path="native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" + target_dir="resource-monitor-publish/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "$source_path" "$target_dir/$binary_name" + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: desktop-${{ inputs.platform }}-${{ inputs.arch }} + path: release-publish/* + if-no-files-found: error + + - name: Upload resource monitor + uses: actions/upload-artifact@v7 + with: + name: resource-monitor-${{ inputs.resource_key }} + path: resource-monitor-publish/${{ inputs.resource_key }}/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39e485d42a98..998e5fd7eacb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" + - "!v*-preview.*" schedule: # Avoid minute zero, when GitHub scheduled jobs are busiest. - cron: "8,38 * * * *" @@ -18,6 +19,7 @@ on: options: - stable - nightly + - preview version: description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." required: false @@ -30,7 +32,7 @@ on: # newest-wins single slot, so a queued stable tag can never be silently # dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: - group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly' || inputs.channel == 'preview') && 'nightly' || 'stable' }} cancel-in-progress: false queue: max @@ -72,7 +74,7 @@ jobs: if (context.eventName === 'schedule') { core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); core.setOutput('ref', context.sha); - } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') { + } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly' && process.env.DISPATCH_CHANNEL !== 'preview') { const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); core.setOutput('ref', sha); @@ -144,6 +146,28 @@ jobs: echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then + # Manual-only test train: exercises the whole release flow for a + # commit end users must never receive. Never scheduled. + # Same versioning as nightly under its own prerelease identifier. + # A preview release is reachable only by asking for it: npm gets it + # under the `preview` dist-tag, which nothing resolves by default, + # its desktop builds carry no update feed, and no updater manifest + # is attached to the release, so neither stable nor nightly + # installs can ever be offered one. + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + + node scripts/resolve-nightly-release.ts \ + --channel preview \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$NIGHTLY_SHA" \ + --github-output + + echo "release_channel=preview" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=preview" >> "$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:-$NIGHTLY_VERSION}" @@ -325,117 +349,23 @@ jobs: echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" - # node-pty publishes no Linux prebuilt and the WSL backend runs under the - # distro's own (Linux) Node, which can't load the Windows/Electron binary. We - # build the Linux pty.node here, on Linux, and hand it to the Windows packaging - # job — the Windows artifact then ships a ready WSL backend binary with no - # cross-compiling and no first-launch compiler/node-gyp/network on the user's - # machine. node-pty is N-API, so one binary works across all WSL Node versions. - build_wsl_node_pty: - name: Build WSL node-pty (linux-x64) + # The platform-independent JS (server bundle, web client, Electron main) is + # built exactly once here and handed to every platform job as `js-bundle`. + # The relay/Clerk values are baked into the bundle, so they belong to this + # job rather than to the packaging jobs. + build_bundle: + name: Build JS bundle # Same gating as relay_public_config: only the release commit is needed, so # this runs alongside preflight. See the condition comment there. - needs: [resolve_commit] - if: | - needs.resolve_commit.result == 'success' && - (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.resolve_commit.outputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - - name: Build node-pty linux-x64 prebuild - shell: bash - run: | - set -euo pipefail - # Resolve node-pty from apps/server (where it's a dependency) and build - # its native binary from source for Linux. node-addon-api resolves from - # node-pty's own dependency tree, so node-gyp has everything it needs. - pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" - pty_dir="$(dirname "$pty_pkg")" - ( cd "$pty_dir" && npx --yes node-gyp rebuild ) - mkdir -p wsl-prebuild - cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node - file wsl-prebuild/pty.node - - - name: Upload node-pty linux-x64 prebuild - uses: actions/upload-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild/pty.node - if-no-files-found: error - - build: - name: Build ${{ matrix.label }} - # build_wsl_node_pty stays in `needs` so it runs first and its artifact is - # available to download, but only the Windows matrix entry consumes it. We - # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring - # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux - # builds. `!cancelled()` (not `!failure()`) lets the job run even when - # build_wsl_node_pty failed; the Windows-only download step below then fails - # that single platform if the prebuild is missing. - needs: [preflight, relay_public_config, build_wsl_node_pty] + needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: ${{ matrix.runner }} + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} - strategy: - fail-fast: false - matrix: - include: - - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: arm64 - rust_target: aarch64-apple-darwin - resource_key: darwin-arm64 - - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: x64 - rust_target: x86_64-apple-darwin - resource_key: darwin-x64 - - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 - platform: linux - target: AppImage - arch: x64 - rust_target: x86_64-unknown-linux-gnu - resource_key: linux-x64 - - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - target: nsis - arch: x64 - rust_target: x86_64-pc-windows-msvc - resource_key: win32-x64 - # - label: Windows arm64 - # runner: windows-11-arm - # platform: win - # target: nsis - # arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -450,22 +380,9 @@ jobs: uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json - cache: ${{ matrix.platform != 'win' }} + cache: true run-install: false - - name: Resolve Windows package cache path - if: matrix.platform == 'win' - id: package_cache_path - shell: pwsh - run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - - - name: Cache Windows packages - if: matrix.platform == 'win' - uses: actions/cache@v6 - with: - path: ${{ steps.package_cache_path.outputs.path }} - key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -475,33 +392,10 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Install bundle dependencies env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} - key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Cache Linux capture helpers - if: matrix.platform == 'linux' - id: capture_helper_cache - uses: actions/cache@v6 - with: - path: | - native/kde-snap-shot/target/${{ matrix.rust_target }}/release/t3-kde-snap-shot - native/hyprland-snap-shot/target/${{ matrix.rust_target }}/release/t3-hyprland-snap-shot - key: linux-capture-helpers-${{ matrix.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (matrix.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.rust_target }} + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -520,275 +414,206 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - name: Download WSL node-pty prebuild - if: matrix.platform == 'win' - uses: actions/download-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild - - - 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.Runtimes.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 - } - - uses: ./.github/actions/setup-apt-mirrors - if: matrix.platform == 'linux' - - - name: Install Linux desktop build libraries - if: matrix.platform == 'linux' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get install -y imagemagick - fi - - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi - - - 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: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} - T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} - 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: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then - echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 - exit 1 - fi - - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - - profile_path="$RUNNER_TEMP/t3code.provisionprofile" - printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" - security cms -D -i "$profile_path" >/dev/null - export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" - export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" - - echo "macOS signing enabled." - args+=(--signed) - else - echo "macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Bundle the Linux node-pty binary built by the build_wsl_node_pty job - # so the packaged WSL backend ships a ready binary (no first-launch - # compile). Required for a working WSL backend on Windows. - args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") - if has_all \ - "$AZURE_TENANT_ID" \ - "$AZURE_CLIENT_ID" \ - "$AZURE_CLIENT_SECRET" \ - "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - vp run dist:desktop:artifact "${args[@]}" - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-publish - - shopt -s nullglob - for pattern in \ - "release/*.dmg" \ - "release/*.zip" \ - "release/*.AppImage" \ - "release/*.exe" \ - "release/*.blockmap" \ - "release/*.yml"; do - for file in $pattern; do - cp "$file" release-publish/ - done - done - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - 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: Collect resource monitor - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ matrix.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" - target_dir="resource-monitor-publish/${{ matrix.resource_key }}" - mkdir -p "$target_dir" - cp "$source_path" "$target_dir/$binary_name" + # @t3tools/desktop#build compiles the Linux browser secret helper on a + # Linux host before packing, and that needs libsecret headers. + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* - if-no-files-found: error + # Runs t3#build (which depends on @t3tools/web#build) and + # @t3tools/desktop#build, so apps/server/dist holds the server bundle + # plus the web client and apps/desktop/dist-electron the Electron main. + - name: Build JS bundle + run: vp run build:desktop - - name: Upload resource monitor + # Two paths under apps/ so the artifact root is apps/; consumers download + # into `apps` to restore both at their original locations. + - name: Upload JS bundle uses: actions/upload-artifact@v7 with: - name: resource-monitor-${{ matrix.resource_key }} - path: resource-monitor-publish/${{ matrix.resource_key }}/* + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron if-no-files-found: error + retention-days: 1 + # One job per platform and architecture (see release-desktop.yml), each on + # hardware of its own architecture, and each gated only on what it consumes: + # every platform needs the JS bundle, and the Windows jobs also need the + # same-arch Linux job, whose CLI archive they embed as the WSL runtime. Every + # job builds the desktop app; all but macOS x64 also build the CLI archive + # for their platform, so a target either ships fully or not at all. + desktop_mac_arm64: + name: Desktop macOS arm64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: macOS arm64 + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: true + + desktop_mac_x64: + name: Desktop macOS x64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: macOS x64 + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: x64 + rust_target: x86_64-apple-darwin + resource_key: darwin-x64 + # No CLI archive: Node single-executables are unsupported on x64 macOS + # (the SEA docs list macOS as arm64 only) and the built binary segfaults + # on start. The x64 desktop app is Electron and unaffected. + cli_archive: false + + desktop_linux_x64: + name: Desktop Linux x64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Linux x64 + runner: blacksmith-32vcpu-ubuntu-2404 + platform: linux + target: AppImage + arch: x64 + rust_target: x86_64-unknown-linux-gnu + resource_key: linux-x64 + cli_archive: true + + # node-pty has no Linux prebuild and compiles from source, so the arm64 app + # and archive are built on arm64 hardware rather than cross-built. + desktop_linux_arm64: + name: Desktop Linux arm64 + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Linux arm64 + runner: ubuntu-24.04-arm + platform: linux + target: AppImage + arch: arm64 + rust_target: aarch64-unknown-linux-gnu + resource_key: linux-arm64 + cli_archive: true + + # The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime. + # `!cancelled()` (not `!failure()`) still lets them start when that Linux job + # failed; the download step inside then fails this single platform if the + # archive is missing. + desktop_win_x64: + name: Desktop Windows x64 + needs: [preflight, relay_public_config, build_bundle, desktop_linux_x64] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Windows x64 + runner: blacksmith-32vcpu-windows-2025 + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + cli_archive: true + + desktop_win_arm64: + name: Desktop Windows arm64 + needs: [preflight, relay_public_config, build_bundle, desktop_linux_arm64] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + version: ${{ needs.preflight.outputs.version }} + ref: ${{ needs.preflight.outputs.ref }} + release_channel: ${{ needs.preflight.outputs.release_channel }} + clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.relay_public_config.outputs.relay_url }} + label: Windows arm64 + runner: windows-11-arm + platform: win + target: nsis + arch: arm64 + rust_target: aarch64-pc-windows-msvc + resource_key: win32-arm64 + cli_archive: true + + # npm gets the same bytes as the GitHub Release: the launcher plus one + # package per CLI archive. Preview publishes too, under the `preview` + # dist-tag, which nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} + needs: + [ + preflight, + relay_public_config, + quality, + desktop_mac_arm64, + desktop_linux_x64, + desktop_linux_arm64, + desktop_win_x64, + desktop_win_arm64, + ] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read id-token: write - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 @@ -807,56 +632,45 @@ jobs: run-install: | args: - --filter=t3... - - --filter=@t3tools/web... - --filter=@t3tools/scripts... - - name: Download relay client tracing config + - name: Download all CLI archives uses: actions/download-artifact@v8 with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + pattern: cli-* + merge-multiple: true + path: release-cli - # The t3 build task depends on @t3tools/web#build, so the web client is - # built (once) as part of this step. - - name: Build CLI package - run: vp run --filter t3 build + - name: Build npm packages from CLI archives + run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages - - name: Download resource monitors - uses: actions/download-artifact@v8 - with: - pattern: resource-monitor-* - path: ${{ runner.temp }}/resource-monitors - - - name: Bundle resource monitors into CLI package - shell: bash + # A dry run of every package first: an auth or scope error here (the + # @t3code org missing, a package without a trusted publisher) fails + # before anything is live, instead of after some platforms already are. + - name: Check npm publish access (dry run) run: | - set -euo pipefail - for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do - resource_key="${artifact_dir##*/resource-monitor-}" - target_dir="apps/server/dist/resource-monitor/${resource_key}" - mkdir -p "$target_dir" - cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" - chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true - done + if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then + echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3- package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 + exit 1 + fi - - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose + - name: Publish CLI packages + run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose release: name: Publish GitHub Release - needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + needs: + [ + preflight, + desktop_mac_arm64, + desktop_mac_x64, + desktop_linux_x64, + desktop_linux_arm64, + desktop_win_x64, + desktop_win_arm64, + publish_cli, + ] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -887,7 +701,51 @@ jobs: merge-multiple: true path: release-assets + - name: Download all CLI archives + uses: actions/download-artifact@v8 + with: + pattern: cli-* + merge-multiple: true + path: release-assets + + # Installers verify archives against this file, so it is written from the + # signed bytes that get uploaded, never from an earlier stage. + - name: Write CLI archive checksums + shell: bash + run: | + set -euo pipefail + cd release-assets + shopt -s nullglob + archives=(t3-*.tar.gz t3-*.zip) + if [[ ${#archives[@]} -eq 0 ]]; then + echo "No CLI archives were produced." >&2 + exit 1 + fi + sha256sum "${archives[@]}" > SHA256SUMS + cat SHA256SUMS + + # The desktop build omits the publish config for preview versions, so + # electron-builder emits no updater manifests for them. Refuse to publish + # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview + # release is what would let a stable or nightly install update onto it. + - name: Refuse updater metadata on preview releases + if: needs.preflight.outputs.release_channel == 'preview' + shell: bash + run: | + set -euo pipefail + shopt -s nullglob extglob + # builder-debug.yml is electron-builder's config dump, not a feed. + updater_files=(release-assets/!(builder-debug).yml release-assets/*.blockmap) + if [[ ${#updater_files[@]} -ne 0 ]]; then + printf 'Preview releases must not carry updater metadata, found: %s\n' "${updater_files[*]}" >&2 + exit 1 + fi + + # electron-updater reads one manifest per platform and channel and picks + # the file entry whose name carries the running arch, so the per-arch + # manifests the build jobs wrote are merged back into that one file. - name: Merge macOS updater manifests + if: needs.preflight.outputs.release_channel != 'preview' run: | shopt -s nullglob for x64_manifest in release-assets/*-mac-x64.yml; do @@ -898,6 +756,60 @@ jobs: fi done + - name: Merge Windows updater manifests + if: needs.preflight.outputs.release_channel != 'preview' + run: | + shopt -s nullglob + for x64_manifest in release-assets/*-win-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml" + merged_manifest="${x64_manifest%-win-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest" + rm -f "$x64_manifest" "$arm64_manifest" + else + mv "$x64_manifest" "$merged_manifest" + fi + done + + # Updater manifests and blockmaps are what electron-updater consumes. + # They are only listed for channels an updater is meant to follow. + - id: release_files + name: Resolve release asset list + shell: bash + run: | + { + echo 'files<> "$GITHUB_OUTPUT" + + # A preview release gets a warning instead of generated notes. Generated + # notes would list every commit since the previous preview, which is + # unmerged branch history no one should read as a changelog, and would + # make the release look like any other build to someone browsing the + # releases page. + - name: Write preview release notes + if: needs.preflight.outputs.release_channel == 'preview' + shell: bash + run: | + cat > release-notes.md <<'EOF' + > [!WARNING] + > **This is a preview build. Do not install it unless you know exactly why you are here.** + > + > Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, are never offered as updates, and are not supported. If you want T3 Code, install the [latest release](https://github.com/pingdotgg/t3code/releases/latest) or a nightly instead. + + Built from `${{ needs.preflight.outputs.ref }}`. + EOF + - name: Publish release if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v3 @@ -905,17 +817,12 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true + generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} + body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} 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 + files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} @@ -926,23 +833,18 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true + generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} + body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} 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 + files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} publish_aur: name: Publish AUR package needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} uses: ./.github/workflows/publish-aur.yml with: release_tag: ${{ needs.preflight.outputs.tag }} @@ -952,7 +854,7 @@ jobs: deploy_web: name: Deploy hosted web app needs: [preflight, relay_public_config, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 env: @@ -1206,6 +1108,7 @@ jobs: if: | always() && !cancelled() && needs.preflight.result == 'success' && + needs.preflight.outputs.release_channel != 'preview' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.deploy_web.result == 'success' && diff --git a/.gitignore b/.gitignore index 79f8735b25e5..8482c5a290e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.log *.tsbuildinfo apps/*/dist +apps/*/dist-exe infra/*/dist .astro packages/*/dist diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 99d7f3cd22ba..8ed88559cfd8 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -2,7 +2,7 @@ title: Effect Service Conventions model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index d2e450235baa..87285d7b0881 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,8 +1,8 @@ --- title: UI Consistency -model: gpt-5-6-terra +model: gpt-5-6-sol effort: medium -input: full_diff +input: incremental tools: - browse_code - modify_pr diff --git a/AGENTS.md b/AGENTS.md index e3b5771d797c..ccf1fdc1d85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. -- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. -- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. +- To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. ## Test data diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e6abaab03251..365363881f5b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -158,18 +158,43 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances" ); const bootstrap = Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; const snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); + const settings = yield* desktopSettings.get; + // The renderer is served from the bundled client (or Vite in development) + // rather than through the local backend, so the window can open without one. + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + ...(environment.isDevelopment + ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } + : { assetDirectory: environment.clientAssetsDir }), + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + + yield* snapShot.initialize; + + if (!settings.localEnvironmentEnabled) { + yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMainIfBackendReady; + } + return; + } + + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } @@ -186,7 +211,6 @@ const bootstrap = Effect.gen(function* () { }, ); - const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { mode: settings.serverExposureMode, @@ -194,16 +218,6 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - const rendererTarget = environment.isDevelopment - ? Option.getOrThrow(environment.devServerUrl) - : backendConfig.httpBaseUrl; - yield* electronProtocol.registerDesktopProtocol({ - scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), - targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, - clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, - }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); @@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () { "bootstrap fell back to local-only because no advertised network host was available", ); } - yield* snapShot.initialize; - - yield* installDesktopIpcHandlers(); - yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - // In wsl-only mode the renderer is served by the WSL backend, which can be - // slow to cold-boot — show a "Connecting to WSL" splash immediately so the - // app feels responsive instead of presenting no window until WSL is ready. - // (Dual mode opens fast off the Windows primary, so no splash there.) + // The main window waits for the primary backend. In wsl-only mode that is + // the WSL backend, which can be slow to cold-boot — show a "Connecting to + // WSL" splash immediately so the app feels responsive instead of presenting + // no window until WSL is ready. (Dual mode opens fast off the Windows + // primary, so no splash there.) if (settings.wslOnly === true && settings.wslBackendEnabled === true) { yield* desktopWindow.showConnectingSplash; } diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 262097ca78ea..1ebd5dae56c2 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -120,6 +120,10 @@ describe("DesktopEnvironment", () => { environment.backendEntryPath, "/install/resources/server.asar/apps/server/dist/bin.mjs", ); + assert.equal( + environment.clientAssetsDir, + "/install/resources/server.asar/apps/server/dist/client", + ); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 9d7f00c3ee69..e604cb767f3f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -61,6 +61,8 @@ export class DesktopEnvironment extends Context.Service< // extracts on demand (see DesktopWslServerTree). readonly serverRoot: string; readonly backendEntryPath: string; + // Built web client the packaged renderer is served from over t3code://app. + readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; readonly appUpdateYmlPath: string; @@ -211,6 +213,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( appRoot, serverRoot, backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), + clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 747663b80ac0..642a1bc82f42 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => { runtimeId: string; sha256: string; }> = []; - const observedNodePtyRoots: string[] = []; + const observedProbeRoots: string[] = []; let legacyCleanupCount = 0; const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; + const resolvedPath = "/home/test/.local/bin:/usr/bin:/bin"; return withPackagedWslHarness( { @@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => { }); return { ok: true, linuxAppRoot }; }, - ensureNodePty: (_distro, root) => { - observedNodePtyRoots.push(root); - return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + probeRuntime: (_distro, root) => { + observedProbeRoots.push(root); + return { ok: true, resolvedPath }; + }, + // The staged runtime carries its own Node, so the preflight must not + // go looking for one in the distro. + ensureNodePty: () => { + throw new Error("the staged runtime must not probe for Node"); }, }), }, @@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => { sha256: archiveHash, }, ]); - assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]); + assert.deepEqual(observedProbeRoots, [linuxAppRoot]); assert.equal( config.entryPath, path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), ); - assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`); + assert.deepEqual(config.args, [ + "-d", + "Ubuntu", + "--exec", + "env", + `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`, + `${linuxAppRoot}/t3`, + "--bootstrap-fd", + "0", + ]); + assert.notInclude(config.args, "/usr/bin/node"); assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); assert.equal(legacyCleanupCount, 1); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); + assert.include(first.args, `/runtime/sha256-${firstHash}/t3`); assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); + assert.include(second.args, `/runtime/sha256-${secondHash}/t3`); assert.isUndefined(invalidIdentity.wslRuntimeId); + assert.include(invalidIdentity.args, "/usr/bin/node"); assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); }), ); @@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); assert.equal(config.entryPath, mountedEntryPath); + assert.include(config.args, "/usr/bin/node"); assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); assert.isUndefined(config.wslRuntimeId); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => { ); }); - it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => { + it.effect("resolveWsl retires a staged runtime whose executable does not start", () => { const archiveHash = "c".repeat(64); const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; + const observedProbeRoots: string[] = []; const observedNodePtyRoots: string[] = []; const invalidatedRuntimeIds: string[] = []; return withPackagedWslHarness( @@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), + probeRuntime: (_distro, root) => { + observedProbeRoots.push(root); + return { ok: false, reason: `${root}/t3 --version failed (exit 127)` }; + }, ensureNodePty: (_distro, root) => { observedNodePtyRoots.push(root); - return root === stagedAppRoot - ? { ok: false, reason: "pty.node could not be loaded", fatal: true } - : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; }, }), }, @@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]); + assert.deepEqual(observedProbeRoots, [stagedAppRoot]); + assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); + assert.include(config.args, "/usr/bin/node"); assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + assert.notInclude(config.args, `${stagedAppRoot}/t3`); assert.equal(config.entryPath, mountedEntryPath); assert.isUndefined(config.wslRuntimeId); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), - ensureNodePty: (_distro, root) => ({ + probeRuntime: () => ({ ok: false, - reason: - root === stagedAppRoot - ? "unsupported CPU architecture or incompatible system libraries" - : "mounted tree is broken in some other way", + reason: "unsupported CPU architecture or incompatible system libraries", + }), + ensureNodePty: () => ({ + ok: false, + reason: "mounted tree is broken in some other way", fatal: true, }), }), @@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => { Effect.sync(() => { invalidatedRuntimeIds.push(runtimeId); }), - ensureNodePty: (_distro, root) => - root === stagedAppRoot - ? { ok: false, reason: "pty.node could not be loaded", fatal: true } - : { - ok: false, - reason: "WSL backend preflight timed out while probing for Node.js.", - fatal: false, - }, - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "timed out"); - assert.deepEqual(invalidatedRuntimeIds, []); - }), - ); - }); - - it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => { - const invalidatedRuntimeIds: string[] = []; - return withPackagedWslHarness( - { - archiveHash: "e".repeat(64), - forbidFallback: "A transient probe failure must not extract the fallback", - forbidCleanup: "A transient probe failure must not clean the fallback tree", - wsl: () => ({ - prepareRuntime: () => ({ - ok: true, - linuxAppRoot: "/home/test/.t3/wsl-runtime/cache", - }), - invalidateRuntime: (_distro, runtimeId) => - Effect.sync(() => { - invalidatedRuntimeIds.push(runtimeId); - }), + probeRuntime: () => ({ ok: false, reason: "t3 --version failed (exit 1)" }), ensureNodePty: () => ({ ok: false, reason: "WSL backend preflight timed out while probing for Node.js.", fatal: false, - retryLimit: 12, }), }), }, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d7c524d16815..3486daebf79e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -95,6 +95,11 @@ const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const nodeBinDirOf = (nodePath: string): string => { + const lastSlash = nodePath.lastIndexOf("/"); + return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin"; +}; + const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); @@ -210,18 +215,31 @@ interface SharedBootstrapInput { readonly observabilitySettings: BackendObservabilitySettings; } +// What the launch runs inside the distro. The staged runtime is the release's +// self-contained `t3` executable (Node inside); the mounted server tree is a +// script that needs the distro's own Node. +type WslPreflightRuntime = + | { + readonly kind: "executable"; + readonly entryPath: string; + } + | { + readonly kind: "node-script"; + // Absolute path to the node binary the preflight validated after the + // shared remote resolver repaired PATH. The launch must use this exact + // path so it doesn't fall through to a different/old node than the one + // node-pty was probed with. + readonly nodePath: string; + readonly linuxEntryPath: string; + }; + interface WslPreflightSuccess { readonly _tag: "Ready"; readonly runningDistro: string; readonly windowsEntryPath: string; - readonly linuxEntryPath: string; - // Absolute path to the node binary the preflight validated after the shared - // remote resolver repaired PATH. The launch must use this exact path so it - // doesn't fall through to a different/old node than the one node-pty was - // built against. - readonly nodePath: string; - // PATH captured from the same login shell after the shared resolver loaded - // version managers. The launch forwards this value directly without a shell. + readonly runtime: WslPreflightRuntime; + // PATH captured from the user's login shell. The launch forwards this value + // directly without a shell so the server can spawn provider CLIs by name. readonly resolvedPath: string; // Identifies the distro-local runtime cache selected from the packaged archive. readonly runtimeId?: string; @@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f // fatal verdict the cached reason is the more actionable one to report. // A transient mounted failure is neither — it rules nothing out, so it stays // retryable and the staged verdict waits for an attempt that can answer. - let stagedFailure: - | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult } - | undefined; + let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; + const failedStaged = (failure: { readonly reason: string }) => + ({ + _tag: "Failed", + reason: `WSL runtime unavailable: ${failure.reason}`, + fatal: true, + }) as const; if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - const stagedNodePty = yield* wslEnv.ensureNodePty( - runningDistro, - runtime.linuxAppRoot, - nodePtyOptions, - ); - if (stagedNodePty.ok) { + // The staged runtime is self-contained, so the only question is whether + // it runs here; there is no Node to find or node-pty to load. + const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); + if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; return { _tag: "Ready", runningDistro, windowsEntryPath: environment.backendEntryPath, - linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, - nodePath: stagedNodePty.nodePath, - resolvedPath: stagedNodePty.resolvedPath, + runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` }, + resolvedPath: stagedProbe.resolvedPath, runtimeId: input.runtimeArchive.runtimeId, } as const; } - // A transport failure says nothing about the staged tree, so it is - // retried against the same cache rather than spending a second probe on - // the mounted tree and risking a needless reinstall. - if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty); yield* Effect.logWarning( - "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.", - { reason: stagedNodePty.reason }, + "The staged WSL runtime did not start; retrying from the mounted server tree.", + { reason: stagedProbe.reason }, ); - stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty }; + stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason }; } else { yield* Effect.logWarning( "Could not stage the WSL runtime; launching from the mounted server tree instead.", @@ -399,7 +414,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f const mounted = yield* resolveMountedAppRoot; if (!mounted.ok) { return stagedFailure && mounted.fatal - ? failedNodePty(stagedFailure.nodePty) + ? failedStaged(stagedFailure) : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); } @@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f // turn a retryable failure into a fatal one, ending the WSL attempt (and, // in wsl-only mode, persisting Windows) before the slow /mnt path had a // chance to answer and clear the bad cache. - return failedNodePty( - stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult, - ); + return stagedFailure && nodePtyResult.fatal + ? failedStaged(stagedFailure) + : failedNodePty(nodePtyResult); } // The mounted tree runs what the cache could not, so the cache is the broken @@ -429,8 +444,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f _tag: "Ready", runningDistro, windowsEntryPath: mounted.windowsEntryPath, - linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, - nodePath: nodePtyResult.nodePath, + runtime: { + kind: "node-script", + nodePath: nodePtyResult.nodePath, + linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, + }, resolvedPath: nodePtyResult.resolvedPath, } as const; }); @@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl runtimeId: `sha256-${archiveHash}`, sha256: archiveHash, }, - // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and - // attached to the Windows artifact — see build-desktop-artifact.ts), so the - // WSL backend never needs a compiler, node-gyp, or network on first launch. - // Compiling from source is a dev-only convenience: a checkout has no shipped - // prebuilt, and developers have the toolchain. In packaged builds we instead - // surface a clear diagnostic if the prebuilt can't load (unsupported - // arch/distro), rather than silently dropping into a fragile runtime build. + // Packaged builds run the self-contained Linux runtime and, on fallback, + // whatever Linux node-pty the mounted tree carries, so the WSL backend never + // needs a compiler, node-gyp, or network on first launch. Compiling from + // source is a dev-only convenience: a checkout has no Linux binary, and + // developers have the toolchain. In packaged builds we instead surface a + // clear diagnostic if the binary can't load (unsupported arch/distro), + // rather than silently dropping into a fragile runtime build. allowBuild: !environment.isPackaged, }); @@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // The WSL server spawns commands its providers reference by name — `npm`/`npx` // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those - // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on + // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on // the process PATH, so `npm install -g ...` fails with NotFound. Pass the - // user PATH entries captured by the login-shell preflight. Every dynamic - // value is a separate argv entry under `wsl.exe --exec`; no shell command is - // involved, so Windows cannot mangle nested quotes and stdin remains reserved - // for the bootstrap envelope. - const lastSlash = preflight.nodePath.lastIndexOf("/"); - const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin"; - const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; + // user PATH entries captured by the preflight. Every dynamic value is a + // separate argv entry under `wsl.exe --exec`; no shell command is involved, + // so Windows cannot mangle nested quotes and stdin remains reserved for the + // bootstrap envelope. A node-script runtime additionally leads with the + // probed Node's bin dir so the server cannot pick up a different node than + // the one node-pty was probed with. + const runtime = preflight.runtime; + const launchPath = + runtime.kind === "executable" + ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}` + : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; + const command = + runtime.kind === "executable" + ? [runtime.entryPath] + : [runtime.nodePath, runtime.linuxEntryPath]; return { ...baseConfig, @@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl "--exec", "env", `PATH=${launchPath}`, - preflight.nodePath, - preflight.linuxEntryPath, + ...command, "--bootstrap-fd", "0", ...devUrlArgs, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index eb0becee0981..0914167cffb0 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 0d204fb3ad42..508a5c296898 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,6 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ @@ -16,6 +19,8 @@ vi.mock("electron", () => ({ import * as ElectronProtocol from "./ElectronProtocol.ts"; +const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer)); + describe("ElectronProtocol", () => { beforeEach(() => { handleMock.mockReset(); @@ -23,6 +28,46 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it.effect("serves the bundled client from disk without a backend", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.writeFileString(`${directory}/index.html`, "app"); + yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;"); + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + assetDirectory: directory, + clerkFrontendApiHostname: undefined, + }); + const request = (pathname: string, init?: RequestInit) => + Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); + + // SPA routes fall back to index.html, including ones containing dots. + const page = yield* request("/settings/connections"); + assert.equal(yield* Effect.promise(() => page.text()), "app"); + assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'"); + const dottedRoute = yield* request("/environment/thread.with.dots", { + headers: { accept: "text/html" }, + }); + assert.equal(yield* Effect.promise(() => dottedRoute.text()), "app"); + + const script = yield* request("/app.js?v=1"); + assert.equal(yield* Effect.promise(() => script.text()), "export default 1;"); + assert.include(script.headers.get("content-type") ?? "", "javascript"); + + assert.equal((yield* request("/missing.js")).status, 404); + assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404); + assert.equal((yield* request("/%invalid")).status, 400); + assert.equal((yield* request("/", { method: "POST" })).status, 405); + assert.equal(netFetchMock.mock.calls.length, 0); + }).pipe(Effect.provide(Layer.merge(protocolLayer, NodeServices.layer)), Effect.scoped), + ); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; @@ -37,7 +82,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -85,7 +129,7 @@ describe("ElectronProtocol", () => { assert.isNull(forwardedHeaders.get("referer")); assert.isNull(forwardedHeaders.get("sec-fetch-site")); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("rejects custom protocol requests for another host", () => @@ -101,7 +145,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); @@ -110,7 +153,7 @@ describe("ElectronProtocol", () => { assert.equal(response.status, 404); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("retries transient renderer target failures", () => @@ -129,7 +172,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:5733/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); @@ -138,7 +180,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ready"); assert.equal(netFetchMock.mock.calls.length, 2); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol registration failures", () => @@ -153,7 +195,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -162,7 +203,7 @@ describe("ElectronProtocol", () => { assert.equal(error.scheme, "t3code-dev"); assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol unregistration failures", () => @@ -178,7 +219,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -192,14 +232,13 @@ describe("ElectronProtocol", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); } - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index af0366f93f47..ed35bbc7952f 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,7 +1,10 @@ +import Mime from "@effect/platform-node/Mime"; 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 NodeTimersPromises from "node:timers/promises"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError decodeURIComponent(url.pathname)).pipe( + Effect.orElseSucceed(() => null), + ); + if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); + const root = path.resolve(assetDirectory); + const assetPath = path.resolve(root, `.${pathname}`); + if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { + return new Response(null, { status: 404 }); + } + const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null)); + let filePath = assetPath; + if (stat?.type !== "File") { + const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; + if (path.extname(assetPath) !== "" && !wantsHtml) { + return new Response(null, { status: 404 }); + } + filePath = path.join(root, "index.html"); + } + const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); + if (contents === null) return new Response(null, { status: 404 }); + return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { + headers: { "content-type": Mime.getType(filePath) ?? "application/octet-stream" }, + }); +}); + async function fetchWithTransientRetry(url: string, init: RequestInit): Promise { let lastError: unknown; @@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( function* (input: DesktopProtocolRegistrationInput) { @@ -220,9 +264,15 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), - ); + Electron.protocol.handle(input.scheme, async (request) => { + if ("assetDirectory" in input) { + return withContentSecurityPolicy( + await runPromise(serveDesktopAsset(request, input.assetDirectory)), + contentSecurityPolicy, + ); + } + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); + }); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6f9bac7333f4..c97c602552f4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -8,6 +8,10 @@ import { getConnectionCatalog, setConnectionCatalog, } from "./methods/connectionCatalog.ts"; +import { + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./methods/localEnvironment.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -79,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); + yield* ipc.handleSync(getLocalEnvironmentEnabled); + yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 7106c45af8e8..226793657848 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -25,6 +25,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; +export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; +export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts new file mode 100644 index 000000000000..e17c48948098 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -0,0 +1,63 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; +import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; + +// `relaunch` declares the lifecycle runtime services as requirements even +// though the mocked relaunch never touches them. +const unusedLifecycleRuntimeLayer = Layer.mergeAll( + DesktopShutdown.layer, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of( + {} as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + Layer.mock(DesktopWindow.DesktopWindow, {}), + Layer.mock(ElectronApp.ElectronApp, {}), + Layer.mock(ElectronTheme.ElectronTheme, {}), +); + +describe("local environment IPC", () => { + it.effect("relaunches only when the setting changes and keeps other settings", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + }), + Layer.mock(DesktopLifecycle.DesktopLifecycle, { + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + }), + unusedLifecycleRuntimeLayer, + ); + return Effect.gen(function* () { + yield* setLocalEnvironmentEnabled.handler(false); + assert.isFalse(yield* getLocalEnvironmentEnabled.handler()); + yield* setLocalEnvironmentEnabled.handler(false); + assert.deepEqual(relaunchReasons, ["localEnvironmentEnabled=false"]); + + yield* setLocalEnvironmentEnabled.handler(true); + assert.isTrue(yield* getLocalEnvironmentEnabled.handler()); + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + assert.isTrue((yield* appSettings.get).wslBackendEnabled); + assert.deepEqual(relaunchReasons, [ + "localEnvironmentEnabled=false", + "localEnvironmentEnabled=true", + ]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts new file mode 100644 index 000000000000..74cccd04a0c5 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +export const getLocalEnvironmentEnabled = makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.localEnvironment.getEnabled")(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + return (yield* appSettings.get).localEnvironmentEnabled; + }), +}); + +export const setLocalEnvironmentEnabled = makeIpcMethod({ + channel: IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.localEnvironment.setEnabled")(function* (enabled) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setLocalEnvironmentEnabled(enabled); + if (change.changed) { + yield* lifecycle.relaunch(`localEnvironmentEnabled=${enabled}`); + } + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 6fcf5e813749..eca3db4ddf85 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -19,6 +19,8 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, @@ -208,19 +210,21 @@ describe("pasteAsText", () => { }); describe("pickProjectFavicon", () => { + const pickerLayer = (pickFiles: () => Effect.Effect>, settings?: DesktopSettings) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + DesktopAppSettings.layerTest(settings), + ); + it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon.handler("/project").pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - }), - ), - ), - ); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); assert.strictEqual(result, "/pictures/icon.png"); assert.deepEqual(pickFiles.mock.calls, [ @@ -240,4 +244,21 @@ describe("pickProjectFavicon", () => { ]); }), ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 284b62ad31ac..81361db37303 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -182,6 +182,11 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* appSettings.get; + // A picked path only means something to a backend on this machine. + if (!settings.localEnvironmentEnabled) { + return null; + } // Three picker modes: // - targetEnvironmentId omitted: default to the primary picker. Keeps // the historical behavior unchanged for users who never enabled the @@ -200,7 +205,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - const settings = yield* appSettings.get; // Fall back to the persisted wslDistro when the id is the // "wsl:default" sentinel; the orchestrator uses the same fallback // for the actual backend. @@ -246,6 +250,10 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) { const dialog = yield* ElectronDialog.ElectronDialog; const electronWindow = yield* ElectronWindow.ElectronWindow; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + if (!(yield* appSettings.get).localEnvironmentEnabled) { + return null; + } const paths = yield* dialog.pickFiles({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: Option.fromNullishOr(initialPath), diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 38435e286fa7..bfd1a6e679d9 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -18,7 +18,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts" import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; -import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; +import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); @@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ); describe("WSL IPC", () => { + it.effect("does not probe WSL when local execution is disabled", () => + Effect.gen(function* () { + const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const state = yield* getWslState.handler(undefined).pipe( + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { + ...wsl, + isAvailable: Effect.die("must not probe WSL"), + listDistros: Effect.die("must not enumerate distros"), + }), + Effect.flatMap(decodeWslState), + ); + assert.deepEqual(state, { + enabled: true, + distro: "Ubuntu", + available: false, + wslOnly: true, + distros: [], + preflightError: null, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: true, + }), + DesktopWslEnvironment.layerTest(), + makeWslBackendLayer(), + ), + ), + ), + ); + it.effect("stages dual-backend preferences before enabling without relaunching", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 1d0dc262baea..37cd992bb641 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -21,7 +21,7 @@ const readWslState: Effect.Effect< const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; - const available = yield* wslEnvironment.isAvailable; + const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); // Only enumerate distros when WSL is actually available — listDistros on a // non-WSL host would spawn wsl.exe and hit the timeout for nothing. const distros = available ? yield* wslEnvironment.listDistros : []; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 939b88c7d0d0..0d626a51955d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,7 +17,6 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -86,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap( }), ); +// The remote runs the exact release this app is on, from its self-contained +// archive, so it needs neither Node nor npm. Development points the remote at +// a source checkout instead so the two sides can be iterated together. const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], - settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -97,24 +98,14 @@ const resolveDesktopSshCliRunner = ( nodeEngineRange: serverPackageJson.engines.node, }; } - return { - packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: environment.appVersion, - updateChannel: settings.updateChannel, - isDevelopment: environment.isDevelopment, - }), - nodeEngineRange: serverPackageJson.engines.node, - }; + return { archiveVersion: environment.appVersion }; }; 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)), - ), + resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), }); }), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4c9a8199de68..453879d37afe 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), + getLocalEnvironmentEnabled: () => + ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, + setLocalEnvironmentEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..f7db3c277810 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -91,6 +91,24 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect( + "persists disabling and re-enabling local execution without clearing backend settings", + () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslDistro("Ubuntu"); + yield* settings.setServerExposureMode("network-accessible"); + const before = yield* settings.get; + assert.isTrue((yield* settings.setLocalEnvironmentEnabled(false)).changed); + assert.deepEqual(yield* settings.load, { ...before, localEnvironmentEnabled: false }); + assert.isFalse((yield* settings.setLocalEnvironmentEnabled(false)).changed); + yield* settings.setLocalEnvironmentEnabled(true); + assert.deepEqual(yield* settings.load, before); + }), + ), + ); it.effect("loads defaults when no settings file exists", () => withSettings( Effect.gen(function* () { @@ -106,6 +124,7 @@ describe("DesktopSettings", () => { DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -135,6 +154,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -242,6 +262,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -298,6 +319,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -346,6 +368,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -374,6 +397,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -401,6 +425,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 3bd235018022..19fcf0e75962 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly localEnvironmentEnabled: boolean; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + localEnvironmentEnabled: true, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -152,6 +155,9 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setLocalEnvironmentEnabled: ( + enabled: boolean, + ) => Effect.Effect; readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, @@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -247,6 +254,10 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) { + document.localEnvironmentEnabled = settings.localEnvironmentEnabled; + } + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -370,6 +381,12 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.localEnvironmentEnabled === enabled + ? settings + : { ...settings, localEnvironmentEnabled: enabled }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -545,6 +562,10 @@ export const make = Effect.gen(function* () { persist((settings) => setWslOnly(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), ), + setLocalEnvironmentEnabled: (enabled) => + persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -586,6 +607,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setWslBackendEnabled(settings, enabled)), setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), + setLocalEnvironmentEnabled: (enabled) => + update((settings) => setLocalEnvironmentEnabled(settings, enabled)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5ad23ee70582..53044dcf7e5d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -57,7 +57,6 @@ const clientSettings: ClientSettings = { proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, - sidebarCompactThreadRows: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 3b135eb2508f..b0094f6867bc 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service< >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -168,13 +167,10 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined + SshTunnel.SshEnvironmentManager.layer( + options.resolveCliRunner === undefined ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), + : { resolveCliRunner: options.resolveCliRunner }, + ), ), ); diff --git a/apps/desktop/src/updates/updateChannels.test.ts b/apps/desktop/src/updates/updateChannels.test.ts new file mode 100644 index 000000000000..acf4155fab8e --- /dev/null +++ b/apps/desktop/src/updates/updateChannels.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; + +describe("updateChannels", () => { + it("keeps preview builds branded as nightly but on the latest update channel", () => { + expect(isNightlyDesktopVersion("0.0.41-preview.20260911.7")).toBe(true); + expect(resolveDefaultDesktopUpdateChannel("0.0.41-preview.20260911.7")).toBe("latest"); + expect(resolveDefaultDesktopUpdateChannel("0.0.41-nightly.20260911.7")).toBe("nightly"); + }); + + it("only matches the first prerelease identifier", () => { + expect(isNightlyDesktopVersion("1.2.3-foo-preview.20260911.1")).toBe(false); + expect(isNightlyDesktopVersion("1.2.3")).toBe(false); + }); +}); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 731910e441fe..e7f9a2f8547d 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,11 +1,18 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; -const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; +// Preview builds are the maintainers' test train, cut by hand from unreleased +// branches to exercise the release flow. They share nightly's branding but +// are packaged without an update feed (see +// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the +// channel a preview install reports is cosmetic: it never checks for updates +// and no updater feed ever lists a preview release. +const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { - return NIGHTLY_VERSION_PATTERN.test(version); + return PRERELEASE_VERSION_PATTERN.test(version); } export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; + return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest"; } diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index cd1404a50464..fbcbb349f9e7 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -196,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7bbb5c1da024..338a02b26a1f 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -253,6 +253,7 @@ function makeTestLayer(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); @@ -629,6 +630,37 @@ describe("DesktopWindow", () => { }), ); + it.effect( + "opens and reopens the window without backend readiness when local execution is disabled", + () => + 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, + createdWindowOptions: [], + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + }); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.createMainIfBackendReady; + assert.equal(yield* Ref.get(createCount), 1); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.dispatchMenuAction("new-thread"); + assert.equal(yield* Ref.get(createCount), 3); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0a966ec36e4d..e19a75962126 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -84,7 +84,7 @@ export class DesktopWindow extends Context.Service< readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only - // mode), before the WSL backend that serves the renderer is ready. It is + // mode), before the WSL backend that acts as the primary is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the @@ -838,9 +838,15 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); + // With the local environment disabled there is no backend to wait for: the + // renderer is served from bundled assets and only talks to remote environments. + const waitingForBackend = Effect.gen(function* () { + if (yield* Ref.get(backendReadyRef)) return false; + return (yield* desktopSettings.get).localEnvironmentEnabled; + }); + const createMainIfBackendReady = Effect.gen(function* () { - const backendReady = yield* Ref.get(backendReadyRef); - if (!backendReady) return; + if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; yield* createMain; @@ -898,7 +904,7 @@ export const make = Effect.gen(function* () { { reveal = true }: { readonly reveal?: boolean } = {}, ) { const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; - if (Option.isNone(existingWindow) && (!reveal || !(yield* Ref.get(backendReadyRef)))) return; + if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; if (targetWindow.isDestroyed()) return; const send = Effect.sync(() => { diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index ed8911d40075..c2daf352837f 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -83,6 +83,29 @@ const netLayer = Layer.succeed(NetService.NetService, { } satisfies NetService.NetService["Service"]); describe("DesktopWslBackend", () => { + it.effect("does not discover or start WSL when local execution is disabled", () => + Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + yield* backend.reconcile; + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provide(Layer.mock(DesktopBackendPool.DesktopBackendPool, {})), + Layer.provide(backendConfigurationLayer), + Layer.provide(serverExposureLayer), + Layer.provide(netLayer), + Layer.provide(Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {})), + Layer.provide( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + }), + ), + ), + ), + ), + ); it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; const primary = makeStubInstance({ diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 605f4e7a477f..3f20e58aa680 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -188,6 +188,7 @@ export const layer = Layer.effect( const reconcileBody = Effect.gen(function* () { const settings = yield* appSettings.get; + if (!settings.localEnvironmentEnabled) return; const available = yield* wslEnvironment.isAvailable; const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index e1188e1a3387..366bdfce9946 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => { return line.slice(field.length + 1).trim(); }; -const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; +// Stands in for the release's self-contained `t3` executable: the install +// script only asks it for `--version`. +const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => @@ -164,21 +166,22 @@ describe("WSL runtime cache", () => { expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); expect(script).toContain(' [ -f "$ready_marker" ] &&'); - expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); - expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); - expect(script).toContain(' node_pty_payload_present "$runtime_root"'); + expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); expect(script).toContain("if runtime_is_ready; then"); + expect(script).not.toContain("bin.mjs"); + expect(script).not.toContain("node-pty"); expect(script).toContain("trap 'exit 1' HUP INT TERM"); expect(script).toContain('exec 9> "$runtime_lock"'); expect(script).toContain("flock -x 9"); expect(script).not.toContain('rm -rf "$runtime_lock"'); expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); + // The release archive wraps everything in one `t3--linux-x64/` + // directory; stripping it puts the executable at `$runtime_root/t3`. expect(script).toContain( - "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"", + "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", ); - expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"'); - expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"'); + expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); expect(script).not.toContain('rm -rf "$runtime_root"'); @@ -248,46 +251,39 @@ describe("WSL runtime cache", () => { expect(deleted).toBeGreaterThan(kept); }); - it("treats a runtime whose native payload went missing as a cache miss", () => { + it("treats a runtime whose executable no longer runs as a cache miss", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - // A glob, not a mapped `uname -m`: this is a presence check, and the later - // native probe is what judges arch and loadability. - expect(script).toContain( - ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', - ); - // The marker the probe reads must sit beside the binary, or the runtime is - // just as unusable as one missing pty.node outright. - expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue'); + // The same proof the SSH runner and CLI installers use: executable, and + // `--version` exits 0. That is what decides arch and loadability, so no + // separate native probe is needed. + expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); - // Readiness gates the short-circuit, so a cache missing the payload + // Readiness gates the short-circuit, so a cache whose executable broke // reinstalls from the archive instead of being reused forever. - const payloadCheckDefined = script.indexOf("node_pty_payload_present() {"); + const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); const readinessDefined = script.indexOf("runtime_is_ready() {"); const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - expect(payloadCheckDefined).toBeGreaterThan(-1); - expect(payloadCheckDefined).toBeLessThan(readinessDefined); + expect(entryCheckDefined).toBeGreaterThan(-1); + expect(entryCheckDefined).toBeLessThan(readinessDefined); expect(readinessDefined).toBeLessThan(readyShortCircuit); }); - // A truncated or half-written bin.mjs passes every presence check the cache - // had: the file exists, node-pty still loads, and launch then picks a server - // that exits before it becomes ready — forever, because nothing ever - // reinstalls. The digest the install records is what turns that into a miss. - it("re-hashes the server entry against the digest the install recorded", () => { + // A swapped or half-written `t3` can still exist and even still answer + // `--version`, and launch then runs something this install never verified. + // The digest the install records is what turns that into a miss. + it("re-hashes the executable against the digest the install recorded", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - expect(script).toContain( - ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, - ); + expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); expect(script).toContain( ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', ); @@ -310,18 +306,18 @@ describe("WSL runtime cache", () => { expect(promoted).toBeGreaterThan(markerWritten); }); - it("refuses to mark an archive without a native payload as ready", () => { + it("refuses to mark an archive whose executable does not run as ready", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", "1.2.3-x64", "b".repeat(64), ); - expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then'); + expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); // The extracted tree is rejected before the ready marker is written, so a // defective archive falls back to the mounted tree instead of caching. - const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"'); + const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); expect(payloadValidated).toBeGreaterThan(-1); @@ -351,7 +347,7 @@ describe("WSL runtime cache", () => { it("never deletes a runtime another backend is running from", () => { const script = buildWslRuntimePruneScript("1.2.3/x64"); - // The running backend's argv holds `/apps/server/dist/bin.mjs`, so + // The running backend's argv holds `/t3`, so // the process itself is the lease and exiting releases it. Nothing has to be // registered up front, which is what makes this cover backends already // running from an older version that knows nothing about pruning. @@ -394,7 +390,7 @@ describe("WSL runtime cache", () => { }); // Reading the generated script proves what it says, not what it does. A cache -// whose bin.mjs was truncated satisfied every assertion above and still got +// whose entry was truncated satisfied every assertion above and still got // reused, so these run the real script against a real archive in a throwaway // HOME and check the outcome. describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { @@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed [ "set -eu", "work=$(mktemp -d)", - 'stage="$work/stage"', - 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"', - `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`, - `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`, - `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`, - `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`, - `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`, + // Mirrors the release archive: one top-level versioned directory that + // holds the executable and its native addons. + 'stage="$work/stage/t3-0.0.0-linux-x64"', + 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', + `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, + 'chmod +x "$stage/t3"', + `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, + `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, `printf 'work:%s\\n' "$work"`, `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, ].join("\n"), @@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed runtimeId, runtimeParent: `${work}/home/.t3/wsl-runtime`, runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, - serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`, + serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`, installScript, install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), }; @@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); }); - it("reinstalls a cache whose server entry was truncated", () => { + it("reinstalls a cache whose executable was truncated", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); @@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `runtime_root=${sh(fixture.runtimeRoot)}`, `runtime_parent=${sh(fixture.runtimeParent)}`, 'rm "$runtime_root/.t3code-wsl-runtime-ready"', - 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + 'sh -c "sleep 30" "$runtime_root/t3" >/dev/null 2>&1 &', "active_pid=$!", "sleep 0.1", fixture.installScript(), @@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'home="$work/home"', 'runtime_parent="$home/.t3/wsl-runtime"', 'mkdir -p "$runtime_parent"', - 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', + 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', "make_ready sha256-current", "make_ready sha256-previous", "make_ready sha256-active", @@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', - 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + 'sh -c "sleep 30" "$runtime_parent/sha256-active/t3" >/dev/null 2>&1 &', "active_pid=$!", "(", ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 79d2213d6990..ebd4f853da60 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult = readonly retryLimit?: number; }; +// Outcome of asking the staged self-contained runtime to prove itself. Any +// failure sends the launch to the mounted server tree; the caller decides what +// to do with the cache. +export type ProbeWslRuntimeResult = + | { + readonly ok: true; + readonly resolvedPath: string; + } + | { + readonly ok: false; + readonly reason: string; + }; + export class DesktopWslDistroListError extends Schema.TaggedError()( "DesktopWslDistroListError", { reason: Schema.String }, @@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service< readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; + // Proves a staged self-contained runtime can run (`/t3 --version`) + // and captures the user's login-shell PATH for the launch. Needs no Node + // in the distro; the mounted server tree still goes through ensureNodePty. + readonly probeRuntime: ( + distro: string | null, + linuxAppRoot: string, + ) => Effect.Effect; readonly ensureNodePty: ( distro: string | null, linuxAppRoot: string, @@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = { const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], + subject = "Node.js", ): string | null => { switch (failure) { case "timeout": - return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; + return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`; case "spawn": - return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; + return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`; case "process": - return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; + return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`; case null: return null; } @@ -255,7 +276,7 @@ const runWslShell = ( const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; -// Holds the sha256 of the runtime's server entry, written when the install +// Holds the sha256 of the runtime's `t3` executable, written when the install // promotes a verified tree. Presence alone only says an install once finished // here; the digest is what lets a later launch prove the entry still is what // that install wrote. @@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = ( 'runtime_parent="$HOME/.t3/wsl-runtime"', `runtime_root="$runtime_parent/${safeRuntimeId}"`, `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, - // The native payload is the part of the tree the WSL backend actually - // dlopens, and the only part a user can plausibly break by hand. Checking - // node-pty's package.json alone let a runtime whose pty.node had gone - // missing stay cache-ready forever: every launch reused it and then failed - // the native probe, with no reinstall and no fallback. Match on the glob - // rather than a mapped `uname -m` so this stays a presence check; the probe - // is what decides whether the binary is the right arch and loadable. - "node_pty_payload_present() {", - ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', - ' [ -f "$candidate" ] || continue', - ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue', - " return 0", - " done", - " return 1", + // The runtime is a self-contained `t3` executable with Node inside, so the + // readiness proof is the same one the SSH runner and the CLI installers + // use: the file is executable and `t3 --version` exits 0. That covers the + // truncated-binary and wrong-arch cases without a separate native probe. + "runtime_entry_runs() {", + ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1', "}", - // Hashing the server entry is the only check that can tell a working cache - // from one whose bin.mjs was truncated or half-written: the file is still - // there, the native probe still passes, and launch then picks a server that - // exits before it can become ready, on every restart. Hashing the ~7MB - // entry measures in single-digit milliseconds inside the distro, once per - // launch, against a cold reinstall of a few hundred megabytes. + // Hashing the entry is what tells a working cache from one whose `t3` was + // swapped or half-written after install: the file is still there and may + // even still run, and launch then picks an executable that is not what + // this install verified. Hashing the executable measures in tens of + // milliseconds inside the distro, once per launch, against a cold + // reinstall of a few hundred megabytes. "runtime_server_entry_digest() {", - ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, + ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`, "}", "runtime_is_ready() {", ' [ -f "$ready_marker" ] &&', - ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&', - ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&', - ' node_pty_payload_present "$runtime_root" &&', + ' runtime_entry_runs "$runtime_root" &&', // An empty or unreadable marker is a miss, not a pass: that is what a // runtime installed before the marker carried a digest looks like, and one // reinstall is the cheapest way to make it verifiable from then on. @@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = ( `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', "trap cleanup_runtime_install EXIT", - `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`, - 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"', - 'test -f "$runtime_tmp/node_modules/node-pty/package.json"', - - // Never write the ready marker over a tree that is missing the native - // payload. Failing here drops out to the mounted-tree fallback, which is + // The release archive has one top-level `t3--linux-/` + // directory; strip it so the executable lands at `$runtime_root/t3`. + `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`, + // Never write the ready marker over a tree whose executable does not run. + // Failing here drops out to the mounted-tree fallback, which is // recoverable; promoting it would mark the defect ready and cache it. - 'if ! node_pty_payload_present "$runtime_tmp"; then', - " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2", + 'if ! runtime_entry_runs "$runtime_tmp"; then', + " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2", " exit 1", "fi", // The archive's bytes were verified against archiveSha256 above, so the @@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => { }; // Drops the ready marker so the next launch reinstalls the runtime from the -// archive. Readiness is a presence check by design, so a cached tree whose -// native payload is present but unloadable (truncated pty.node, a distro whose -// glibc the binary needs and the tree was copied from another machine) stays -// ready forever and fails the probe on every launch. Only the probe can see -// that, so the probe is what revokes the marker. The tree itself is left in -// place: the install script moves an unready root aside before extracting. +// archive. Readiness is decided inside the install script, so a cached tree +// that passes there but fails the launch-time probe (a distro whose glibc the +// executable needs, a tree copied from another machine) would stay ready +// forever and fail on every launch. Only the probe can see that, so the probe +// is what revokes the marker. The tree itself is left in place: the install +// script moves an unready root aside before extracting. export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); return [ @@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { return runtimeRoot.startsWith("/") ? runtimeRoot : null; }; -const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; +// The mounted server tree carries no Linux pty.node unless the build put one +// there. Distinct from a binary that is present but will not load, which is a +// distro problem rather than a build problem. +const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; const formatNodePtyProbeFailureReason = (exitCode: number): string | null => - exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE - ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild ` or install a build that includes WSL support." + exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE + ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." : null; +// Captures the login-shell PATH as `resolvedPath:` so the launch can forward the +// user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name. +const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`; + const NODE_PTY_PROBE_SCRIPT = ( linuxServerDir: string, ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" -printf 'resolvedPath:%s\\n' "$PATH" +${RESOLVED_PATH_LINE} cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The WSL Node can't read inside app.asar, so confirm what the server needs is // unpacked on the real filesystem before reporting the backend healthy. Exit 3 -// marks this distinct from a node-pty prebuild problem so the caller can report +// marks this distinct from a node-pty binary problem so the caller can report // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // @@ -517,26 +534,27 @@ const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across -// Node versions — require() succeeding IS the real compatibility test. Compare -// only arch and node-pty version (a stale binary from a different node-pty), -// NOT process.versions.modules: that would reject a perfectly loadable prebuilt -// whenever the user's WSL Node ABI differs from the build's, defeating the -// whole point of shipping one prebuilt for all Node versions. -const expected = { - arch: process.arch, - nodePtyVersion: require("node-pty/package.json").version, -}; -const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch); -const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json"); -const binary = path.join(prebuildDir, "pty.node"); -if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE}); +// Node versions — require() succeeding IS the real compatibility test. Look in +// the same places node-pty's own loader does. +const candidates = [ + path.join(pkgDir, "build", "Release", "pty.node"), + path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"), +]; +if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE}); require("node-pty"); -const actual = JSON.parse(fs.readFileSync(marker, "utf8")); -for (const key of Object.keys(expected)) { - if (actual[key] !== expected[key]) process.exit(2); -} NODE`; +// Readiness proof for a staged self-contained runtime: the executable runs and +// reports its version, and the login shell's PATH is captured for the launch. +// This runs under plain `sh` (no Node resolver preamble, since the runtime +// needs no Node), so the login shell is entered explicitly for the PATH +// capture; a distro without bash falls back to the PATH sh was started with. +const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => + [ + `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, + `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, + ].join("\n"); + const TOOLCHAIN_CHECK_SCRIPT = [ "for tool in node make g++ python3; do", ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', @@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => "set -e", `cd ${shellQuote(linuxServerDir)}`, `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, - `arch=$(node -p "process.arch")`, - `modules=$(node -p "process.versions.modules")`, - `node_pty_version=$(node -p "require('node-pty/package.json').version")`, `cd "$pkg_dir"`, "npx --yes node-gyp rebuild", - `prebuild_dir="prebuilds/linux-$arch"`, - `mkdir -p "$prebuild_dir"`, - `cp build/Release/pty.node "$prebuild_dir/pty.node"`, - `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, `node -e 'require("node-pty")'`, ].join("\n"); @@ -660,6 +671,38 @@ export const formatMissingToolsReason = ( return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; }; +const probeWslRuntimeImpl = ( + distro: string | null, + linuxAppRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { + resolveNode: false, + }); + const transportFailureReason = formatWslShellTransportFailureReason( + probe.transportFailure, + "the staged runtime", + ); + if (transportFailureReason !== null) { + return { ok: false, reason: transportFailureReason } as const; + } + if (probe.exitCode !== 0) { + const trimmedTail = probe.stderr.trim().slice(-500); + return { + ok: false, + reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, + } as const; + } + const resolvedPath = parseResolvedPath(probe.stdout); + if (resolvedPath === null) { + return { + ok: false, + reason: "WSL login-shell PATH could not be resolved during backend preflight.", + } as const; + } + return { ok: true, resolvedPath } as const; + }); + const ensureNodePtyImpl = ( distro: string | null, linuxRepoRoot: string, @@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub { ) => PrepareWslRuntimeResult; readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect; + // Defaults to success with a plain PATH: a staged runtime that was prepared + // is assumed to run unless the test says otherwise. + readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult; readonly ensureNodePty?: ( distro: string | null, linuxAppRoot: string, @@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, invalidateRuntime: (distro, runtimeId) => stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, + probeRuntime: (distro, linuxAppRoot) => + Effect.succeed( + stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, + ), ensureNodePty: (distro, linuxAppRoot, options) => Effect.succeed( stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { @@ -1259,6 +1309,10 @@ export const layer = Layer.effect( provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( Effect.withSpan("desktop.wsl.invalidateRuntime"), ), + probeRuntime: (distro, linuxAppRoot) => + provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( + Effect.withSpan("desktop.wsl.probeRuntime"), + ), ensureNodePty: (distro, linuxAppRoot, options) => provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( Effect.withSpan("desktop.wsl.ensureNodePty"), diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 89c11fe6e18c..f3ec31ed34d9 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,9 +1,18 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; const repoEnv = loadRepoEnv(); + +// The main process is bundled the same way the server CLI is: every JS +// dependency is inlined and only packages Node must load from disk stay +// external. The packaged app then installs just those externals, instead of a +// full production install of apps/desktop's dependency tree next to a server +// bundle that already carries its own copy of the same libraries. +const isMainProcessExternal = (id: string) => + id === "electron" || id.startsWith("electron/") || isDesktopRuntimeExternalDependency(id); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; const publicConfigDefine = { __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( @@ -55,7 +64,9 @@ export default defineConfig({ ], clean: true, deps: { - alwaysBundle: (id) => id.startsWith("@t3tools/"), + alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), + neverBundle: isMainProcessExternal, + onlyBundle: false, }, ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), }, diff --git a/apps/marketing/.gitignore b/apps/marketing/.gitignore new file mode 100644 index 000000000000..254b88f1a73f --- /dev/null +++ b/apps/marketing/.gitignore @@ -0,0 +1,2 @@ +/public/install.sh +/public/install.ps1 diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 80a36fc54af4..511552d74fbd 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", + "stage:install-scripts": "node scripts/stage-install-scripts.mjs", + "dev": "node scripts/stage-install-scripts.mjs && astro dev", + "build": "node scripts/stage-install-scripts.mjs && astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/marketing/scripts/stage-install-scripts.mjs b/apps/marketing/scripts/stage-install-scripts.mjs new file mode 100644 index 000000000000..80f458e8acaa --- /dev/null +++ b/apps/marketing/scripts/stage-install-scripts.mjs @@ -0,0 +1,15 @@ +// The CLI install scripts live in scripts/ at the repo root with the rest of +// the release tooling; the site serves them at /install.sh and /install.ps1. +// Copy them into public/ before every Astro build and dev server so the two +// never drift. The copies are gitignored. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); +const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir)); +const publicDir = NodePath.join(marketingDir, "public"); +NodeFS.mkdirSync(publicDir, { recursive: true }); +for (const name of ["install.sh", "install.ps1"]) { + NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name)); +} diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 08a1c008453e..95c6f513f665 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -132,6 +132,9 @@ const imageProps = {

Terminal

npx t3@nightly +

No Node.js? The preview build installs as a single download:

+ curl -fsSL https://t3.codes/install.sh | sh + irm https://t3.codes/install.ps1 | iex @@ -432,6 +435,12 @@ const imageProps = { letter-spacing: -0.01em; } + .cli-note { + color: var(--fg-muted); + font-size: 0.85rem; + margin-top: 0.5rem; + } + .cli-line { align-self: flex-start; font-family: var(--font-mono); diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cb90f3687184..669bdce8a72d 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -444,7 +444,7 @@ const mobileEndorsementRows = [ return assets.find((a) => a.name.endsWith("-arm64.dmg"))?.browser_download_url ?? null; } if (platform.os === "linux") { - return assets.find((a) => a.name.endsWith(".AppImage"))?.browser_download_url ?? null; + return assets.find((a) => a.name.endsWith("-x86_64.AppImage"))?.browser_download_url ?? null; } return null; } diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index e37be215f1a0..1ec71b1c7115 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -7,6 +7,24 @@ export const config: VercelConfig = { installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", + // `curl … | sh` needs the scripts served as plain text, uncompressed by + // content negotiation, and never cached past a deploy. + headers: [ + { + source: "/install.sh", + headers: [ + { key: "Content-Type", value: "text/x-shellscript; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + { + source: "/install.ps1", + headers: [ + { key: "Content-Type", value: "text/plain; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + ], redirects: [ { source: "/app", diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx index 600383765d59..c8cde5d444fe 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -2,12 +2,12 @@ import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui"; import { accessibilityElement, accessibilityLabel, + fixedSize, font, foregroundStyle, frame, layoutPriority, lineLimit, - minimumScaleFactor, progressViewStyle, tint, widgetURL, @@ -33,6 +33,8 @@ function SubscriptionUsage( const accessory = family === "accessoryRectangular"; const compact = family === "systemSmall" || accessory || environment.levelOfDetail === "simplified"; + // Budget short cards for two quotas per provider, including their secondary text. + const dense = family === "systemSmall" || family === "systemMedium"; const limit = family === "systemExtraLarge" ? 6 : family === "systemLarge" ? 4 : 2; const monochrome = environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; @@ -77,6 +79,7 @@ function SubscriptionUsage( : provider.detail; const barModifiers = [ progressViewStyle("linear"), + frame({ height: 4 }), ...(monochrome ? [] : [tint(provider.name === "Claude" ? "#d97757" : "#8e8e93")]), ]; if (accessory) { @@ -99,7 +102,6 @@ function SubscriptionUsage( modifiers={[ font({ textStyle: "caption", weight: "semibold" }), lineLimit(1), - minimumScaleFactor(0.75), foregroundStyle("primary"), ]} > @@ -132,35 +134,37 @@ function SubscriptionUsage( {provider.name} - {(!compact || shown.length === 0) && detail !== "Subscription remaining" ? ( + {!compact || shown.length === 0 ? ( - {detail} + {detail === "Subscription remaining" ? " " : detail} ) : null} {shown.map((window) => ( {window.label} @@ -182,9 +185,11 @@ function SubscriptionUsage( {!compact ? ( - + {window.reset} ) : null} @@ -216,7 +214,13 @@ function SubscriptionUsage( {!compact && !stale && (period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) > limit ? ( - + {(period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) - limit}{" "} more in T3 @@ -228,11 +232,11 @@ function SubscriptionUsage( return ( {compact ? ( - + {columns} ) : ( @@ -243,12 +247,7 @@ function SubscriptionUsage( {!accessory ? : null} {!accessory ? ( {props.checkedAt ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}` diff --git a/apps/server/package.json b/apps/server/package.json index 9b5f2fb2d7d7..f3ee6cda3aef 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,17 +16,16 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", + "build:bundle": "vp pack", + "build:exe": "node scripts/cli.ts build-exe", "start": "node dist/bin.mjs", "typecheck": "tsc --noEmit", "test": "vp test run" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.260", - "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", - "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "effect": "catalog:", @@ -44,7 +43,6 @@ "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", - "@types/bun": "1.3.14", "@types/node": "catalog:", "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", diff --git a/apps/server/resources/cli-entitlements.plist b/apps/server/resources/cli-entitlements.plist new file mode 100644 index 000000000000..3078fc969a73 --- /dev/null +++ b/apps/server/resources/cli-entitlements.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-executable-page-protection + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + + diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 2de5b702a286..cc59e47e0a40 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -6,67 +6,24 @@ 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"; -import { - DEVELOPMENT_ICON_OVERRIDES, - resolveWebAssetBrandForPackageVersion, - resolveWebIconOverrides, -} from "../../../scripts/lib/brand-assets.ts"; -import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; -import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; -import { fromYaml } from "@t3tools/shared/schemaYaml"; +import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; +import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import serverPackageJson from "../package.json" with { type: "json" }; import { ServerCliBuildAssetMissingError, ServerCliCommandExitError, ServerCliDevelopmentIconSourceMissingError, ServerCliDevelopmentIconTargetMissingError, - ServerCliPublishIconSourceMissingError, - ServerCliPublishIconTargetMissingError, + ServerCliExecutableImportError, } from "./cliErrors.ts"; -interface PackageJson { - name: string; - repository: { - type: string; - url: string; - directory: string; - }; - bin: Record; - type: string; - version: string; - engines: Record; - files: string[]; - dependencies: Record; - overrides: Record; -} - -const PackageJsonPrettyJson = fromJsonStringPretty(Schema.Unknown); -const encodePackageJson = Schema.encodeEffect(PackageJsonPrettyJson); - -const WorkspaceConfig = Schema.Struct({ - catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)), - overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}); -type WorkspaceConfig = typeof WorkspaceConfig.Type; -const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); - const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), ); -const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const repoRoot = yield* RepoRoot; - const workspaceYaml = yield* fs.readFileString(path.join(repoRoot, "pnpm-workspace.yaml")); - return yield* decodeWorkspaceConfig(workspaceYaml); -}); - const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(command); @@ -82,36 +39,6 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan } }); -const preparePublishIcons = Effect.fn("preparePublishIcons")(function* ( - repoRoot: string, - serverDir: string, - version: string, -) { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const brand = resolveWebAssetBrandForPackageVersion(version); - const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({ - sourcePath: path.join(repoRoot, override.sourceRelativePath), - targetPath: path.join(serverDir, override.targetRelativePath), - })); - - for (const icon of icons) { - if (!(yield* fs.exists(icon.sourcePath))) { - return yield* new ServerCliPublishIconSourceMissingError({ sourcePath: icon.sourcePath }); - } - if (!(yield* fs.exists(icon.targetPath))) { - return yield* new ServerCliPublishIconTargetMissingError({ targetPath: icon.targetPath }); - } - } - - return yield* Effect.forEach(icons, (icon) => - Effect.all({ - original: fs.readFile(icon.targetPath), - publish: fs.readFile(icon.sourcePath), - }).pipe(Effect.map((contents) => ({ ...icon, ...contents }))), - ); -}); - const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( repoRoot: string, serverDir: string, @@ -176,40 +103,83 @@ const buildCmd = Command.make( ).pipe(Command.withDescription("Build the server package (tsdown + bundle web client).")); // --------------------------------------------------------------------------- -// publish subcommand +// build-exe subcommand // --------------------------------------------------------------------------- -interface PublishCommandConfig { - readonly access: string; - readonly tag: string; - readonly provenance: boolean; - readonly dryRun: boolean; -} +const buildExeCmd = Command.make( + "build-exe", + { + verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), + target: Flag.string("target").pipe( + Flag.withDescription( + "Cross-build for - in nodejs.org naming (for example darwin-x64); defaults to the host.", + ), + Flag.optional, + ), + }, + (config) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const repoRoot = yield* RepoRoot; + const serverDir = path.join(repoRoot, "apps/server"); -const createVpPmPublishArgs = (config: PublishCommandConfig): ReadonlyArray => { - const args = [ - "publish", - "--filter", - "t3", - "--access", - config.access, - "--tag", - config.tag, - "--no-git-checks", - ]; + yield* Effect.log("[cli] Building single-executable..."); + const spawnCommand = yield* resolveSpawnCommand("vp", ["pack"]); + yield* runCommand( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: serverDir, + env: { + ...process.env, + T3CODE_PACK_EXE: "1", + ...Option.match(config.target, { + onNone: () => ({}), + onSome: (target) => ({ T3CODE_PACK_EXE_TARGET: target }), + }), + }, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + shell: spawnCommand.shell, + }), + ); - if (config.provenance) args.push("--provenance"); - if (config.dryRun) args.push("--dry-run"); + // The executable can only `import` built-ins. A file-backed import + // passes the bundler and `node dist/bin.mjs`, then throws inside the + // binary, so read the emitted module graph rather than trusting config. + const bundlePath = path.join(serverDir, "dist-exe/bin.mjs"); + const specifiers = findEsmImportsOfExternalPackages(yield* fs.readFileString(bundlePath)); + if (specifiers.length > 0) { + return yield* new ServerCliExecutableImportError({ bundlePath, specifiers }); + } + yield* Effect.log( + "[cli] Built dist-exe/t3 (expects client/, resource-monitor/, and the runtime-external node_modules beside it; scripts/build-cli-archive.ts assembles that tree)", + ); + }), +).pipe( + Command.withDescription( + "Build the server as a Node single-executable (needs a Node 25.7+ host for --build-sea). The binary still resolves native packages from a node_modules tree beside it.", + ), +); - return args; -}; +// --------------------------------------------------------------------------- +// publish subcommand +// --------------------------------------------------------------------------- +/** + * Publishes the tarballs scripts/build-npm-platform-packages.ts produced: + * every `@t3code/t3-.tgz` first, `t3.tgz` (the launcher) last, so + * the launcher is never installable before the executables it depends on. + * Tarballs rather than directories because `npm publish ` strips the + * `node_modules/` the executable loads its native addons from. + */ const publishCmd = Command.make( "publish", { + packagesDir: Flag.string("packages-dir").pipe( + Flag.withDescription("Output dir of scripts/build-npm-platform-packages.ts."), + ), tag: Flag.string("tag").pipe(Flag.withDefault("latest")), access: Flag.string("access").pipe(Flag.withDefault("public")), - appVersion: Flag.string("app-version").pipe(Flag.optional), provenance: Flag.boolean("provenance").pipe(Flag.withDefault(false)), dryRun: Flag.boolean("dry-run").pipe(Flag.withDefault(false)), verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), @@ -218,90 +188,48 @@ const publishCmd = Command.make( Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const repoRoot = yield* RepoRoot; - const serverDir = path.join(repoRoot, "apps/server"); - const packageJsonPath = path.join(serverDir, "package.json"); - - // Assert build assets exist - for (const relPath of [ - "dist/bin.mjs", - "dist/service-launcher.mjs", - "dist/client/index.html", - ]) { - const abs = path.join(serverDir, relPath); - if (!(yield* fs.exists(abs))) { - return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); - } + // npm runs with cwd set to the packages dir below, so tarball paths are + // resolved once here rather than joined twice. + const packagesDir = path.resolve(config.packagesDir); + const scopeDir = path.join(packagesDir, "@t3code"); + const launcherTarball = path.join(packagesDir, "t3.tgz"); + const platformTarballs = (yield* fs + .readDirectory(scopeDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) + .filter((entry) => entry.startsWith("t3-") && entry.endsWith(".tgz")) + .sort() + .map((entry) => path.join(scopeDir, entry)); + if (platformTarballs.length === 0) { + return yield* new ServerCliBuildAssetMissingError({ + assetPath: path.join(scopeDir, "t3-.tgz"), + }); + } + if (!(yield* fs.exists(launcherTarball))) { + return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball }); } - yield* Effect.acquireUseRelease( - // Acquire: resolve publish metadata and read every original before mutation. - Effect.gen(function* () { - const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); - const workspaceConfig = yield* readWorkspaceConfig(); - const workspaceCatalog = workspaceConfig.catalog ?? {}; - const workspaceOverrides = workspaceConfig.overrides ?? {}; - const pkg: PackageJson = { - name: serverPackageJson.name, - repository: serverPackageJson.repository, - bin: serverPackageJson.bin, - type: serverPackageJson.type, - version, - engines: serverPackageJson.engines, - files: serverPackageJson.files, - dependencies: resolveCatalogDependencies( - serverPackageJson.dependencies, - workspaceCatalog, - "apps/server", - ), - overrides: resolveCatalogDependencies( - workspaceOverrides, - workspaceCatalog, - "apps/server", - ), - }; - - return { - packageJsonString: yield* encodePackageJson(pkg), - originalPackageJson: yield* fs.readFile(packageJsonPath), - icons: yield* preparePublishIcons(repoRoot, serverDir, version), - }; - }), - // Use: pnpm publish from the workspace root so pnpm-only workspace - // config, including override selectors, is interpreted correctly. - (resource) => - Effect.gen(function* () { - yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); - for (const icon of resource.icons) { - yield* fs.writeFile(icon.targetPath, icon.publish); - } - yield* Effect.log("[cli] Applied package metadata and publish icon overrides"); - - const args = createVpPmPublishArgs(config); - const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); - - yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); - yield* runCommand( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: repoRoot, - stdout: config.verbose ? "inherit" : "ignore", - stderr: "inherit", - shell: spawnCommand.shell, - }), - ); - }), - // Release: restore every file even if applying overrides or publishing fails. - (resource) => - Effect.gen(function* () { - yield* fs.writeFile(packageJsonPath, resource.originalPackageJson); - for (const icon of resource.icons) { - yield* fs.writeFile(icon.targetPath, icon.original); - } - if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); + const args = ["publish", "--access", config.access, "--tag", config.tag]; + if (config.provenance) args.push("--provenance"); + if (config.dryRun) args.push("--dry-run"); + + for (const tarball of [...platformTarballs, launcherTarball]) { + const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]); + yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); + yield* runCommand( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: packagesDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + shell: spawnCommand.shell, }), - ); + ); + } }), -).pipe(Command.withDescription("Publish the server package to npm.")); +).pipe( + Command.withDescription( + "Publish the @t3code/t3- tarballs and then the t3 launcher to npm.", + ), +); // --------------------------------------------------------------------------- // root command @@ -309,7 +237,7 @@ const publishCmd = Command.make( const cli = Command.make("cli").pipe( Command.withDescription("T3 server build & publish CLI."), - Command.withSubcommands([buildCmd, publishCmd]), + Command.withSubcommands([buildCmd, buildExeCmd, publishCmd]), ); Command.run(cli, { version: "0.0.0" }).pipe( diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts index d2a410a6e0f3..5c02281aabb1 100644 --- a/apps/server/scripts/cliErrors.ts +++ b/apps/server/scripts/cliErrors.ts @@ -14,28 +14,6 @@ export class ServerCliCommandExitError extends Schema.TaggedError()( - "ServerCliPublishIconSourceMissingError", - { - sourcePath: Schema.String, - }, -) { - override get message(): string { - return `Missing publish icon source: ${this.sourcePath}`; - } -} - -export class ServerCliPublishIconTargetMissingError extends Schema.TaggedError()( - "ServerCliPublishIconTargetMissingError", - { - targetPath: Schema.String, - }, -) { - override get message(): string { - return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`; - } -} - export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedError()( "ServerCliDevelopmentIconSourceMissingError", { @@ -68,3 +46,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedError()( + "ServerCliExecutableImportError", + { + bundlePath: Schema.String, + specifiers: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `${this.bundlePath} imports file-backed packages that a single-executable cannot resolve: ${this.specifiers.join(", ")}. Load them through createRequire instead.`; + } +} diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 028fe53e0191..bfa1b87deb99 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -3,9 +3,13 @@ import { AuthAdministrativeScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PersistenceErrors from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -15,6 +19,8 @@ import * as SessionStore from "./SessionStore.ts"; /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ const TEST_SERVER_PORT = 13_773; +const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError); +const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError); const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( @@ -33,7 +39,7 @@ const makeServerConfigLayer = (overrides?: Partial) => EnvironmentAuth.layer.pipe( - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), @@ -72,6 +78,216 @@ const requestMetadata = { }; it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { + it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const scopedExchange = yield* serverAuth.createBrowserSession( + pairing.credential, + requestMetadata, + ); + const request = { + cookies: { + [sessions.cookieName]: scopedExchange.sessionToken, + [devExchange.cookieName ?? "missing"]: devExchange.sessionToken, + }, + headers: {}, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + + const authenticated = yield* serverAuth.authenticateHttpRequest(request); + expect(devExchange.cookieName).toMatch(/^t3_dev_session_/); + expect(devExchange.expireNormalCookie).toBe(true); + expect(authenticated.scopes).toEqual(["orchestration:read"]); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("does not fall back to the dev cookie after a normal cookie is rejected", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const scopedExchange = yield* serverAuth.createBrowserSession( + pairing.credential, + requestMetadata, + ); + const scoped = yield* sessions.verify(scopedExchange.sessionToken); + yield* sessions.revoke(scoped.sessionId); + const request = { + cookies: { + [sessions.cookieName]: scopedExchange.sessionToken, + [devExchange.cookieName ?? "missing"]: devExchange.sessionToken, + }, + headers: {}, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + + const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request)); + expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("does not use the dev cookie when Authorization is invalid or empty", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + for (const authorization of ["Bearer invalid", ""] as const) { + const request = { + cookies: { [devExchange.cookieName ?? "missing"]: devExchange.sessionToken }, + headers: { authorization }, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request)); + expect(EnvironmentAuth.isServerAuthCredentialError(error)).toBe(true); + } + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("exchanges the reusable dev token for a local scoped OAuth session", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const exchanged = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + ); + + expect(exchanged.access_token).not.toBe(token); + expect(exchanged.scope).toBe("orchestration:read"); + const dpop = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + { proofKeyThumbprint: "test-proof-key" }, + ); + expect(dpop.access_token).not.toBe(token); + expect(dpop.access_token).not.toBe(exchanged.access_token); + expect(dpop.token_type).toBe("DPoP"); + expect(dpop.scope).toBe("orchestration:read"); + + const secondBearer = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + ); + const firstSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(exchanged.access_token), + ); + const secondSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(secondBearer.access_token), + ); + expect(firstSession.subject).toBe("reusable-dev-token-child"); + expect(secondSession.subject).toBe("reusable-dev-token-child"); + expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("uses a one-time startup credential after local dev token revocation", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const initial = yield* serverAuth.issueStartupPairingCredential(); + const seeded = yield* sessions.verify(token); + + yield* sessions.revoke(seeded.sessionId); + const recovery = yield* serverAuth.issueStartupPairingCredential(); + + expect(initial.credential).toBe(token); + expect(recovery.credential).not.toBe(token); + expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); + expect( + (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response, + ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes }); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("keeps the pairing issue error as the immediate recovery failure", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const seeded = yield* sessions.verify(token); + + yield* sessions.revoke(seeded.sessionId); + yield* sql` + CREATE TRIGGER reject_startup_pairing_link + BEFORE INSERT ON auth_pairing_links + BEGIN + SELECT RAISE(ABORT, 'startup pairing insert rejected'); + END + `; + + const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential()); + + expect(error._tag).toBe("ServerAuthPairingLinkCreationError"); + expect(isPairingCredentialIssueError(error.cause)).toBe(true); + if (isPairingCredentialIssueError(error.cause)) { + expect(isPersistenceSqlError(error.cause.cause)).toBe(true); + } + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 964fe6220d6b..481ffc2fad64 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -31,10 +31,12 @@ import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerConfig from "../config.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; +import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; @@ -427,6 +429,8 @@ export class EnvironmentAuth extends Context.Service< { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; + readonly cookieName?: string; + readonly expireNormalCookie?: boolean; }, ServerAuthInvalidCredentialError | ServerAuthInternalError >; @@ -504,6 +508,8 @@ export class EnvironmentAuth extends Context.Service< type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; + readonly cookieName?: string; + readonly expireNormalCookie?: boolean; }; const AUTHORIZATION_PREFIX = "Bearer "; @@ -599,6 +605,8 @@ export const make = Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); + const config = yield* ServerConfig.ServerConfig; + const devAuth = resolveReusableDevAuth(config); const authenticateToken = ( token: string, @@ -630,15 +638,22 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const credential = selectRequestCredential( + const selectedCredential = selectRequestCredential( request, sessions.cookieName, sessions.legacyCookieName, ); + const dpopToken = parseDpopToken(request); + const hasAuthorization = request.headers.authorization !== undefined; + const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined; + const credential = + selectedCredential ?? + (!hasAuthorization && devCookieToken !== undefined + ? { token: devCookieToken, source: "dev-cookie" as const } + : undefined); if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - const dpopToken = parseDpopToken(request); return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { @@ -697,8 +712,32 @@ export const make = Effect.gen(function* () { const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, - ) => - bootstrapCredentials.consume(credential).pipe( + ) => { + if (devAuth?.matches(credential)) { + return sessions.verify(credential).pipe( + mapSessionVerificationErrors, + Effect.flatMap((session) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + response: { + authenticated: true, + scopes: session.scopes, + sessionMethod: session.method, + expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })), + } satisfies AuthBrowserSessionResult, + sessionToken: credential, + cookieName: devAuth.cookieName, + expireNormalCookie: true, + }) satisfies BootstrapExchangeResult, + ), + ), + ), + Effect.withSpan("EnvironmentAuth.createBrowserSession"), + ); + } + return bootstrapCredentials.consume(credential).pipe( Effect.mapError(toBootstrapExchangeError), Effect.flatMap((grant) => sessions @@ -729,11 +768,42 @@ export const make = Effect.gen(function* () { ), Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); + }; + + type ResolvedBootstrapGrant = Pick< + PairingGrantStore.BootstrapGrant, + "scopes" | "subject" | "label" + > & { + readonly method: PairingGrantStore.BootstrapGrant["method"] | "reusable-dev-token"; + }; + const resolveBootstrapGrant = ( + credential: string, + input?: { readonly proofKeyThumbprint?: string }, + ): Effect.Effect< + ResolvedBootstrapGrant, + ServerAuthInvalidCredentialError | ServerAuthInternalError + > => { + if (!devAuth?.matches(credential)) { + return bootstrapCredentials + .consume(credential, input) + .pipe(Effect.mapError(toBootstrapExchangeError)); + } + return sessions.verify(credential).pipe( + mapSessionVerificationErrors, + Effect.map( + (session) => + ({ + method: "reusable-dev-token", + scopes: session.scopes, + subject: "reusable-dev-token-child", + }) satisfies ResolvedBootstrapGrant, + ), + ); + }; const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, input).pipe( - Effect.mapError(toBootstrapExchangeError), + resolveBootstrapGrant(credential, input).pipe( Effect.flatMap((grant) => Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; @@ -917,12 +987,33 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = - () => - issuePairingCredentialForSubject({ + () => { + const fallback = issuePairingCredentialForSubject({ scopes: AuthAdministrativeScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, purpose: "startup", - }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + }); + if (!devAuth) { + return fallback.pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + } + return sessions.verify(devAuth.credential).pipe( + Effect.map( + (session) => + ({ + id: session.sessionId, + credential: devAuth.credential, + label: "Reusable dev token", + expiresAt: DateTime.toUtc(session.expiresAt ?? REUSABLE_DEV_SESSION_EXPIRES_AT), + }) satisfies AuthPairingCredentialResult, + ), + Effect.catch((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? fallback + : Effect.fail(new ServerAuthPairingLinkCreationError({ cause })), + ), + Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"), + ); + }; const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( diff --git a/apps/server/src/auth/ReusableDevAuth.ts b/apps/server/src/auth/ReusableDevAuth.ts new file mode 100644 index 000000000000..5b7d59db8da2 --- /dev/null +++ b/apps/server/src/auth/ReusableDevAuth.ts @@ -0,0 +1,31 @@ +import * as NodeCrypto from "node:crypto"; +import { AuthSessionId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Redacted from "effect/Redacted"; + +import type { ServerConfig } from "../config.ts"; + +export const REUSABLE_DEV_SESSION_PREFIX = "dev-auth-"; +// The database schema requires an expiry for a configured token with no normal session TTL. +export const REUSABLE_DEV_SESSION_EXPIRES_AT = DateTime.makeUnsafe("9999-12-31T23:59:59.999Z"); + +export function resolveReusableDevAuth( + config: Pick, +) { + if (config.mode !== "web" || config.devUrl === undefined || config.devAuthToken === undefined) { + return undefined; + } + const token = config.devAuthToken; + if (Redacted.value(token).length === 0) { + return undefined; + } + const hash = NodeCrypto.createHash("sha256").update(Redacted.value(token)).digest(); + const tokenId = hash.toString("hex"); + return { + credential: Redacted.value(token), + sessionId: AuthSessionId.make(`${REUSABLE_DEV_SESSION_PREFIX}${tokenId}`), + cookieName: `t3_dev_session_${tokenId}`, + matches: (credential: string) => + NodeCrypto.timingSafeEqual(hash, NodeCrypto.createHash("sha256").update(credential).digest()), + }; +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2e2bdea73fc7..ca55e4e95e01 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -111,6 +111,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, + [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1e2d5c60e9c9..72f91e21d16f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -3,9 +3,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; 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 Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -13,7 +15,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; -import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -54,6 +59,32 @@ const relaySessionInput = { client: { label: "Relay desktop", deviceType: "desktop" }, } as const; +const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(function* ( + baseDir: string, + token?: string, +) { + const devUrl = new URL("http://127.0.0.1:5173"); + const paths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { + baseDirIsExplicit: true, + }); + yield* ServerConfig.ensureServerDirectories(paths); + const persistence = makeSqlitePersistenceLive(paths.dbPath); + return SessionStore.layer.pipe( + Layer.provide(persistence), + Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make(baseDir))), + Layer.provide( + makeServerConfigLayer({ + ...paths, + baseDir, + mode: "web", + devUrl, + ...(token === undefined ? {} : { devAuthToken: Redacted.make(token) }), + }), + ), + ); +}); + const repositoryFailure = new PersistenceSqlError({ operation: "AuthSessionRepository.getById:query", detail: "sqlite is unavailable", @@ -62,6 +93,7 @@ const repositoryFailure = new PersistenceSqlError({ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { create: () => Effect.void, createReplacingActive: () => Effect.succeed([]), + createIfAbsent: () => Effect.void, getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), revoke: () => Effect.fail(repositoryFailure), @@ -103,6 +135,125 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }), ); + it.effect("keeps reusable dev auth local across disk-backed stores and restarts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const baseA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-a-" }); + const baseB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-b-" }); + const layerA = yield* makeDiskSessionStoreLayer(baseA, token); + const fromA = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + const local = yield* sessions.issue({ subject: "environment-a" }); + const ticket = yield* sessions.issueWebSocketToken(local.sessionId); + yield* sessions.revoke(dev.sessionId); + return { dev, local, ticket }; + }).pipe(Effect.provide(layerA), Effect.scoped); + + const layerB = yield* makeDiskSessionStoreLayer(baseB, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + expect(dev.sessionId).toBe(fromA.dev.sessionId); + expect((yield* Effect.flip(sessions.verify(fromA.local.token)))._tag).toBe( + "InvalidSessionTokenSignatureError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(fromA.ticket.token)))._tag).toBe( + "InvalidWebSocketTokenSignatureError", + ); + }).pipe(Effect.provide(layerB), Effect.scoped); + + const reopenedB = yield* makeDiskSessionStoreLayer(baseB, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + expect(dev.sessionId).toBe(fromA.dev.sessionId); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + expect((yield* sessions.verifyWebSocketToken(ticket.token)).sessionId).toBe(dev.sessionId); + }).pipe(Effect.provide(reopenedB), Effect.scoped); + + const reopenedA = yield* makeDiskSessionStoreLayer(baseA, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); + }).pipe(Effect.provide(reopenedA), Effect.scoped); + }), + ); + + it.effect("invalidates old dev credentials and tickets after rotation or removal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-rotation-" }); + const oldToken = "old-reusable-dev-auth-token-that-is-long-enough"; + const newToken = "new-reusable-dev-auth-token-that-is-long-enough"; + const initialLayer = yield* makeDiskSessionStoreLayer(baseDir, oldToken); + const old = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(oldToken); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + return { dev, ticket }; + }).pipe(Effect.provide(initialLayer), Effect.scoped); + + const rotatedLayer = yield* makeDiskSessionStoreLayer(baseDir, newToken); + const rotated = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(oldToken)))._tag).toBe( + "MalformedSessionTokenError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(old.ticket.token)))._tag).toBe( + "UnknownWebSocketSessionError", + ); + expect( + (yield* sessions.listActive()).some((row) => row.sessionId === old.dev.sessionId), + ).toBe(true); + const dev = yield* sessions.verify(newToken); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + return { dev, ticket }; + }).pipe(Effect.provide(rotatedLayer), Effect.scoped); + + const removedLayer = yield* makeDiskSessionStoreLayer(baseDir); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(newToken)))._tag).toBe( + "MalformedSessionTokenError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(rotated.ticket.token)))._tag).toBe( + "UnknownWebSocketSessionError", + ); + expect( + (yield* sessions.listActive()).some((row) => row.sessionId === rotated.dev.sessionId), + ).toBe(true); + }).pipe(Effect.provide(removedLayer), Effect.scoped); + }), + ); + + it.effect("keeps the reusable token active after normal sessions expire", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const normal = yield* sessions.issue({ subject: "normal-session" }); + + yield* TestClock.adjust(Duration.days(31)); + + expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); + expect((yield* Effect.flip(sessions.verify(normal.token)))._tag).toBe( + "SessionTokenExpiredError", + ); + }).pipe( + Effect.provide( + Layer.merge( + makeSessionStoreLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + TestClock.layer(), + ), + ), + ), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index be8e7627bb20..1526dd6705d7 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1,5 +1,6 @@ import { AuthSessionId, + AuthAdministrativeScopes, AuthStandardClientScopes, AuthEnvironmentScopes, type AuthClientMetadata, @@ -24,6 +25,11 @@ import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { + REUSABLE_DEV_SESSION_EXPIRES_AT, + REUSABLE_DEV_SESSION_PREFIX, + resolveReusableDevAuth, +} from "./ReusableDevAuth.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, @@ -416,7 +422,6 @@ export class SessionStore extends Context.Service< const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); - const SessionClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("session"), @@ -492,6 +497,31 @@ export const make = Effect.gen(function* () { } as const; const cookieName = resolveSessionCookieName(cookieInput); const legacyCookieName = resolveLegacySessionCookieName(cookieInput); + const devAuth = resolveReusableDevAuth(serverConfig); + if (devAuth) { + yield* authSessions + .createIfAbsent({ + sessionId: devAuth.sessionId, + subject: "reusable-dev-token", + scopes: AuthAdministrativeScopes, + method: "browser-session-cookie", + client: { + label: "Reusable dev token", + ipAddress: null, + userAgent: null, + deviceType: "unknown", + os: null, + browser: null, + }, + issuedAt: yield* DateTime.now, + expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT, + }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }), + ), + ); + } const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -717,6 +747,42 @@ export const make = Effect.gen(function* () { const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( function* (token) { + if (devAuth?.matches(token)) { + const row = yield* authSessions + .getById({ sessionId: devAuth.sessionId }) + .pipe( + Effect.mapError( + (cause) => + new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }), + ), + ); + if (Option.isNone(row)) { + return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionTokenRevokedError({ + sessionId: devAuth.sessionId, + revokedAt: row.value.revokedAt, + }); + } + const observedAt = yield* DateTime.now; + if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { + return yield* new SessionTokenExpiredError({ + sessionId: devAuth.sessionId, + expiresAt: row.value.expiresAt, + observedAt, + }); + } + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + scopes: row.value.scopes, + } satisfies VerifiedSession; + } const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new MalformedSessionTokenError({}); @@ -829,6 +895,9 @@ export const make = Effect.gen(function* () { const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), ); + if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) { + return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); + } const observedAt = yield* DateTime.now; const expiresAt = DateTime.make(claims.exp); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts new file mode 100644 index 000000000000..3d53ee088376 --- /dev/null +++ b/apps/server/src/auth/http.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentHttpApi } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Etag from "effect/unstable/http/Etag"; +import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; + +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./http.ts"; + +const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough"; +class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} + +const configLayer = Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { + ...config, + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make(DEV_TOKEN), + } satisfies ServerConfig.ServerConfig["Service"]; + }), +).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-http-test-" }))); + +const environmentAuthLayer = EnvironmentAuth.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(configLayer), +); +const routesLayer = HttpApiBuilder.layer(AuthTestApi).pipe( + Layer.provide(authHttpApiLayer), + Layer.provide(environmentAuthenticatedAuthLayer), + Layer.provideMerge(environmentAuthLayer), + Layer.provide(configLayer), + Layer.provideMerge( + HttpPlatform.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Etag.layerWeak), + ), + ), + Layer.provide(NodeServices.layer), +); + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const postJson = (path: string, body: unknown, headers?: Readonly>) => + new Request(`http://127.0.0.1${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: encodeJson(body), + }); + +it.effect("sets the selected browser session cookies through the HTTP route", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.die("Not used by these routes."), + remove: () => Effect.void, + }); + const requestContext = Context.make(Crypto.Crypto, crypto).pipe( + Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore), + ); + return yield* Effect.acquireUseRelease( + Effect.sync( + () => + [ + HttpRouter.toWebHandler(routesLayer, { disableLogger: true }), + HttpRouter.toWebHandler(routesLayer, { disableLogger: true }), + ] as const, + ), + ([environmentA, environmentB]) => + Effect.tryPromise(async () => { + const devResponse = await environmentA.handler( + postJson("/api/auth/browser-session", { credential: DEV_TOKEN }), + requestContext, + ); + expect(devResponse.status).toBe(200); + const devCookies = devResponse.headers.getSetCookie(); + const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_")); + expect(devCookie).toContain("HttpOnly"); + expect(devCookie).toContain(`=${DEV_TOKEN};`); + expect(devCookies).toContainEqual( + expect.stringMatching(/^t3_session_[^=]*=;.*Max-Age=0/), + ); + const devCookieHeader = devCookie?.split(";", 1)[0] ?? ""; + const environmentBSession = await environmentB.handler( + new Request("http://127.0.0.1/api/auth/session", { + headers: { cookie: devCookieHeader }, + }), + requestContext, + ); + expect(environmentBSession.status).toBe(200); + expect(await environmentBSession.json()).toMatchObject({ authenticated: true }); + + const pairingResponse = await environmentA.handler( + postJson( + "/api/auth/pairing-token", + { scopes: ["orchestration:read"] }, + { cookie: devCookieHeader }, + ), + requestContext, + ); + expect(pairingResponse.status).toBe(200); + const pairing = (await pairingResponse.json()) as { credential: string }; + const restrictedResponse = await environmentA.handler( + postJson("/api/auth/browser-session", { credential: pairing.credential }), + requestContext, + ); + expect(restrictedResponse.status).toBe(200); + const restrictedCookies = restrictedResponse.headers.getSetCookie(); + expect(restrictedCookies).toHaveLength(1); + expect(restrictedCookies[0]).toMatch(/^t3_session_/); + expect(restrictedCookies[0]).not.toContain("t3_dev_session_"); + }), + ([environmentA, environmentB]) => + Effect.promise(() => Promise.all([environmentA.dispose(), environmentB.dispose()])), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index b50d6eae9a18..0f927580367d 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -273,10 +273,27 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - yield* appendSessionCookie( - sessions.cookieName, - result.sessionToken, - result.response.expiresAt, + const cookieName = result.cookieName ?? sessions.cookieName; + const selectedCookie = yield* Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, result.sessionToken, { + expires: DateTime.toDate(result.response.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); + const sessionCookies = result.expireNormalCookie + ? yield* Effect.fromResult( + Cookies.expireCookie(selectedCookie, sessions.cookieName, { + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))) + : selectedCookie; + + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 52cc363ed04f..1ec78ff2e173 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,7 +17,12 @@ import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { uninstallCommand } from "./cli/uninstall.ts"; +import { updateCommand } from "./cli/update.ts"; +import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; +import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; +import { sshHelperCommand } from "./cli/sshHelper.ts"; import { themeCommand } from "./cli/theme.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -59,7 +64,12 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, serviceCommand, + updateCommand, + uninstallCommand, + serviceLauncherCommand, + claudeHistoryCommand, servicePreflightCommand, + sshHelperCommand, themeCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, diff --git a/apps/server/src/claude-history-worker.ts b/apps/server/src/claude-history-worker.ts new file mode 100644 index 000000000000..2554401dbd64 --- /dev/null +++ b/apps/server/src/claude-history-worker.ts @@ -0,0 +1,7 @@ +// Standalone entry for npm-distributed runtimes: `node claudeHistoryWorker.mjs +// [options]`. The executable reaches the same worker +// through the `__claude-history` subcommand. +import { runClaudeHistoryWorker } from "./claudeHistoryWorker.ts"; + +const [method, sessionId, rawOptions] = process.argv.slice(2); +await runClaudeHistoryWorker(method, sessionId, rawOptions); diff --git a/apps/server/src/claudeHistoryWorker.ts b/apps/server/src/claudeHistoryWorker.ts index d00282bb77f0..f09e0dd78435 100644 --- a/apps/server/src/claudeHistoryWorker.ts +++ b/apps/server/src/claudeHistoryWorker.ts @@ -2,9 +2,13 @@ import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk" import * as Schema from "effect/Schema"; // A separate process gives SDK history helpers the provider's environment without -// mutating the server's environment. This entry is bundled alongside the server. -const [method, sessionId, rawOptions] = process.argv.slice(2); -const options = Schema.decodeSync( +// mutating the server's environment. `claude-history-worker.ts` is the +// standalone entry bundled beside the server for npm installs; the +// single-executable hosts the same function as its `__claude-history` +// subcommand, which has no Node to run a sibling script. Nothing here may run +// on import: inside the executable `import.meta.main` is true for the whole +// bundle. +const decodeHistoryOptions = Schema.decodeSync( Schema.fromJsonString( Schema.Struct({ dir: Schema.optionalKey(Schema.String), @@ -12,14 +16,22 @@ const options = Schema.decodeSync( upToMessageId: Schema.optionalKey(Schema.String), }), ), -)(rawOptions ?? "{}"); -if (!sessionId) throw new Error("Claude history session id is required."); -const result = - method === "getSessionMessages" - ? await getSessionMessages(sessionId, options) - : method === "forkSession" - ? await forkSession(sessionId, options) - : (() => { - throw new Error("Unknown Claude history operation."); - })(); -process.stdout.write(JSON.stringify(result)); +); + +export async function runClaudeHistoryWorker( + method: string | undefined, + sessionId: string | undefined, + rawOptions: string | undefined, +): Promise { + const options = decodeHistoryOptions(rawOptions ?? "{}"); + if (!sessionId) throw new Error("Claude history session id is required."); + const result = + method === "getSessionMessages" + ? await getSessionMessages(sessionId, options) + : method === "forkSession" + ? await forkSession(sessionId, options) + : (() => { + throw new Error("Unknown Claude history operation."); + })(); + process.stdout.write(JSON.stringify(result)); +} diff --git a/apps/server/src/cli/claudeHistory.ts b/apps/server/src/cli/claudeHistory.ts new file mode 100644 index 000000000000..81bef1359703 --- /dev/null +++ b/apps/server/src/cli/claudeHistory.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import { Argument, Command } from "effect/unstable/cli"; + +import { runClaudeHistoryWorker } from "../claudeHistoryWorker.ts"; + +/** + * Hosts the Claude history worker inside the CLI executable. The npm bundle + * runs it as a sibling `claudeHistoryWorker.mjs` under the host Node; the + * single-executable has no Node to run a script with, so the adapter invokes + * this hidden subcommand on its own executable instead. + */ +export const claudeHistoryCommand = Command.make("__claude-history", { + method: Argument.string("method"), + sessionId: Argument.string("session-id"), + options: Argument.string("options").pipe(Argument.optional), +}).pipe( + Command.unlisted, + Command.withHandler(({ method, sessionId, options }) => + Effect.promise(() => + runClaudeHistoryWorker( + method, + sessionId, + options._tag === "Some" ? options.value : undefined, + ), + ), + ), +); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 2267a5cb1cc9..0c28bce28ae5 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -9,6 +9,7 @@ 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 Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { @@ -24,6 +25,7 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const makeDesktopBootstrap = ( overrides: Partial = {}, @@ -73,6 +75,93 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); }); + it.effect("enables a trimmed reusable auth token only for web dev mode", () => + Effect.gen(function* () { + const baseDir = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-" })), + ); + const flags = { + mode: Option.some("web" as const), + port: Option.some(8788), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DEV_AUTH_TOKEN: " reusable-dev-auth-token-that-is-long-enough ", + }, + }), + ); + const web = yield* resolveServerConfig(flags, Option.none()).pipe( + Effect.provide(Layer.mergeAll(configLayer, NetService.layer)), + ); + const desktop = yield* resolveServerConfig( + { ...flags, mode: Option.some("desktop" as const) }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + + expect(web.devAuthToken).toBeDefined(); + if (web.devAuthToken === undefined) { + return yield* Effect.die("Expected reusable dev auth token."); + } + expect(Redacted.value(web.devAuthToken)).toBe("reusable-dev-auth-token-that-is-long-enough"); + expect(desktop.devAuthToken).toBeUndefined(); + }), + ); + + it.effect("does not expose an invalid reusable auth token", () => + Effect.gen(function* () { + const secret = "short-secret"; + const baseDir = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-invalid-" })), + ); + const flags = { + mode: Option.some("web" as const), + port: Option.some(8788), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_DEV_AUTH_TOKEN: secret } }), + ); + const error = yield* resolveServerConfig(flags, Option.none()).pipe( + Effect.provide(Layer.mergeAll(configLayer, NetService.layer)), + Effect.flip, + ); + const desktop = yield* resolveServerConfig( + { ...flags, mode: Option.some("desktop" as const) }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + const staticWeb = yield* resolveServerConfig( + { ...flags, devUrl: Option.none() }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + + expect(String(error)).not.toContain(secret); + const serialized = yield* encodeUnknownJson(error); + expect(serialized).not.toContain(secret); + expect(desktop.devAuthToken).toBeUndefined(); + expect(staticWeb.devAuthToken).toBeUndefined(); + }), + ); + it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 847edbbc4fe0..1759b4b03830 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -8,6 +8,7 @@ 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 Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -141,6 +142,26 @@ const EnvServerConfig = Config.all({ ), }); +const DevAuthTokenConfig = Config.redacted("T3CODE_DEV_AUTH_TOKEN").pipe( + Config.map((token) => Redacted.make(Redacted.value(token).trim())), + Config.mapOrFail((token) => + Redacted.value(token).length === 0 || Redacted.value(token).length >= 32 + ? Effect.succeed(token) + : Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "T3CODE_DEV_AUTH_TOKEN must contain at least 32 characters.", + }), + ), + ), + ), + ), + Config.option, + Config.map(Option.filter((token) => Redacted.value(token).length > 0)), + Config.map(Option.getOrUndefined), +); + export interface CliServerFlags { readonly mode: Option.Option; readonly port: Option.Option; @@ -268,6 +289,8 @@ export const resolveServerConfig = ( resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, ); + const devAuthToken = + mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined; const explicitBaseDir = resolveOptionPrecedence( normalizedFlags.baseDir, Option.fromUndefinedOr(env.t3Home), @@ -373,6 +396,7 @@ export const resolveServerConfig = ( host, staticDir, devUrl, + ...(devAuthToken === undefined ? {} : { devAuthToken }), devAllowedOrigins: env.devAllowedOrigins, noBrowser, startupPresentation, diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 370a8977fc4c..067d9fa09d80 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -43,9 +43,11 @@ it("treats stable installs as direct invocations", () => { } }); -it("re-suggests the nightly channel only for nightly builds", () => { +it("re-suggests the prerelease channel only for prerelease builds", () => { for (const [version, expected] of [ ["0.0.31-nightly.20260729", "npx t3@nightly serve"], + ["0.0.31-preview.20260729.1", "npx t3@preview serve"], + ["0.0.31-foo-preview.20260729.1", "npx t3 serve"], ["0.0.31", "npx t3 serve"], ] as const) { assert.equal( diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 55f5b66ad9dd..1fc0e774129f 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -43,7 +43,8 @@ function detectCliRunner(entryPath: string): CliRunner | null { * anything else suggests the bare package. */ function suggestedPackageSpec(version: string): string { - return version.includes("-nightly.") ? "t3@nightly" : "t3"; + const channel = /^[^-+]+-(nightly|preview)\./.exec(version)?.[1]; + return channel === undefined ? "t3" : `t3@${channel}`; } /** diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 38732e42987a..74c2489e7b12 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -45,7 +45,7 @@ it("reports the installed service version and host paths", () => { it("gives a direct repair command for a stale service", () => { assert.include( formatServiceStatus({ ...status, current: false }, "0.0.29"), - "Next: Run `npx t3@0.0.29 service update`.", + "Next: Run `t3 service install` to repair it.", ); }); @@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe expect(output).toContain("last login session ends"); expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); expect(output).toContain("[service-stopped]"); - expect(output).toContain("npx t3@0.0.32-nightly.1 service update"); - expect(output).not.toContain("t3@latest"); + expect(output).toContain("Run `t3 service install` to repair it."); + expect(output).not.toContain("npx"); }); -it("suggests the newer CLI version when the installed service needs an update", () => { +it("points an older service at a repair, never at npx", () => { const output = formatServiceStatus( { ...status, current: false, installedVersion: "0.0.28" }, "0.0.29", ); - expect(output).toContain("npx t3@0.0.29 service update"); - expect(output).not.toContain("npx t3@0.0.28 service update"); + expect(output).toContain("Run `t3 service install` to repair it."); + expect(output).not.toContain("npx"); }); it("explains where the service is supported", () => { @@ -84,29 +84,33 @@ it("explains where the service is supported", () => { ); }); -it("reports a newer installed service and gives an exact-version repair command", () => { +it("reports a newer installed service and tells the CLI to catch up to it", () => { const output = formatServiceStatus( { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, "0.0.31", ); assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)"); - assert.include(output, "npx t3@0.0.32-nightly.1 service update"); - assert.notInclude(output, "npx t3@latest service update"); + assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it"); + assert.notInclude(output, "npx"); }); const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; function makeTestService(serviceStatus: BootService.BootServiceStatus) { const installOptions: Array[0]> = []; + const restarts: Array = []; const service = BootService.BootService.of({ status: Effect.succeed(serviceStatus), + restart: Effect.sync(() => { + restarts.push(true); + return serviceStatus.installed; + }), install: (options) => Effect.sync(() => { installOptions.push(options); return { - nodePath: "/test/node", - launcherPath: "/test/service-launcher.mjs", + program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"], baseDir: "/test/t3", unitPath: serviceStatus.unitPath, logPath: serviceStatus.logPath, @@ -114,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { }), uninstall: Effect.succeed(false), }); - return { service, installOptions }; + return { service, installOptions, restarts }; } it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { + it.effect("restart restarts the installed service", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions, restarts } = makeTestService(status); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "restart", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + expect(restarts).toEqual([true]); + expect(installOptions).toEqual([]); + }), + ); + it.effect.each(["install", "update"] as const)( "%s refuses a downgrade before changing the service", (command) => diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 0cea18ff4977..580938de69d0 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; @@ -17,7 +18,11 @@ export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) = baseDir: config.baseDir, logsDir: config.logsDir, cliVersion: packageJson.version, - }).pipe(Layer.provide(ProcessRunner.layer)); + }).pipe( + Layer.provide(ProcessRunner.layer), + // Archive-distributed versions download the release archive here. + Layer.provide(FetchHttpClient.layer), + ); export type ServiceReconcileResult = | { @@ -33,6 +38,7 @@ export type ServiceReconcileResult = /** Install, update, or repair the service using the CLI version running this command. */ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { readonly allowDowngrade?: boolean; + readonly start?: boolean; }) { const service = yield* BootService.BootService; const status = yield* service.status; @@ -82,7 +88,7 @@ export function formatServiceStatus( ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...problems, - ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, + ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`, ].join("\n"); } return [ @@ -91,7 +97,7 @@ export function formatServiceStatus( ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...problems, - ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]), + ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), ].join("\n"); } @@ -133,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ), ); +// Kept one release for muscle memory and old docs. It did what `t3 service +// install` does; the way to move to a newer release is `t3 update`. const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( - Command.withDescription( - "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", - ), + Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."), + Command.unlisted, Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { + yield* Console.log( + "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.", + ); const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); @@ -154,6 +164,27 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( ), ); +const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe( + Command.withDescription( + "Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.", + ), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + const status = yield* service.status; + const restarted = yield* service.restart; + yield* Console.log( + restarted + ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.` + : "T3 Code service is not installed.", + ); + }), + ), + ), +); + const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( Command.withDescription("Stop and remove the T3 Code background service."), Command.withHandler((flags) => @@ -260,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe( Command.withDescription("Manage the T3 Code background service."), Command.withSubcommands([ serviceInstallCommand, + serviceRestartCommand, serviceUninstallCommand, - serviceUpdateCommand, serviceStatusCommand, + serviceUpdateCommand, ]), ); diff --git a/apps/server/src/cli/serviceLauncher.ts b/apps/server/src/cli/serviceLauncher.ts new file mode 100644 index 000000000000..280177fc2807 --- /dev/null +++ b/apps/server/src/cli/serviceLauncher.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import { Command } from "effect/unstable/cli"; + +import { main as runServiceLauncher } from "../serviceLauncher.ts"; + +/** + * Hosts the service launcher inside the CLI executable. The service manager + * runs `t3 __service-launcher` and the launcher spawns the server from the + * same executable, so the machine needs no Node to run either. + * + * The launcher owns SIGTERM handling and the process lifetime: it must finish + * stopping its child before the process exits, so it runs detached from the + * CLI's fiber rather than under `runMain`, whose signal handler would + * interrupt the fiber and exit while the child is still being terminated. + */ +export const serviceLauncherCommand = Command.make("__service-launcher").pipe( + Command.unlisted, + Command.withHandler(() => + Effect.sync(() => { + runServiceLauncher().catch((cause: unknown) => { + const error = cause instanceof Error ? cause : new Error(String(cause)); + process.stderr.write(`[service-launcher] ${error.message}\n`); + process.exitCode = 1; + }); + }), + ), +); diff --git a/apps/server/src/cli/sshHelper.ts b/apps/server/src/cli/sshHelper.ts new file mode 100644 index 000000000000..55428538bd8d --- /dev/null +++ b/apps/server/src/cli/sshHelper.ts @@ -0,0 +1,126 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalTimers:off +// @effect-diagnostics globalDateInEffect:off +// The helpers mirror the inline Node snippets the SSH launch script used to +// run, byte for byte in behaviour, so they stay on plain Node APIs. +import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; + +import * as Effect from "effect/Effect"; +import { Argument, Command } from "effect/unstable/cli"; + +/** + * Small helpers the SSH launch script needs on the remote host. The script + * used to run these as inline `node -` snippets; archive-distributed runtimes + * have no Node on the remote, so the executable provides them instead. Output + * and exit codes match the snippets exactly because the shell script parses + * them. + */ + +const tryPort = (port: number) => + new Promise((resolve) => { + const server = NodeNet.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close((error) => resolve(error ? false : port)); + }); + }); + +/** Prints the first free loopback port from the preferred one, scanning `window` ports. */ +const pickPort = Command.make("pick-port", { + portFile: Argument.string("port-file"), + defaultPort: Argument.integer("default-port"), + scanWindow: Argument.integer("scan-window"), +}).pipe( + Command.withHandler(({ portFile, defaultPort, scanWindow }) => + Effect.promise(async () => { + const raw = NodeFS.existsSync(portFile) ? NodeFS.readFileSync(portFile, "utf8").trim() : ""; + const preferred = Number.parseInt(raw, 10); + const start = Number.isInteger(preferred) ? preferred : defaultPort; + for (let port = start; port < start + scanWindow; port += 1) { + if (await tryPort(port)) { + process.stdout.write(String(port)); + return; + } + } + process.exitCode = 1; + }), + ), +); + +const probe = (port: number, probeTimeoutMs: number) => + new Promise((resolve) => { + const request = NodeHttp.get( + { hostname: "127.0.0.1", port, path: "/", timeout: probeTimeoutMs }, + (response) => { + response.resume(); + response.once("end", () => { + const status = response.statusCode ?? 0; + resolve(status >= 200 && status < 300); + }); + }, + ); + request.once("timeout", () => { + request.destroy(); + resolve(false); + }); + request.once("error", () => resolve(false)); + }); + +/** Exits 0 once the loopback server answers, 1 when the deadline passes first. */ +const waitReady = Command.make("wait-ready", { + port: Argument.integer("port"), + timeoutMs: Argument.integer("timeout-ms"), + probeTimeoutMs: Argument.integer("probe-timeout-ms"), +}).pipe( + Command.withHandler(({ port, timeoutMs, probeTimeoutMs }) => + Effect.promise(async () => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await probe(port, probeTimeoutMs)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + process.exitCode = 1; + }), + ), +); + +/** Prints ` ` for a live default-home server, or exits 1. */ +const runtimePort = Command.make("runtime-port", { + runtimeFile: Argument.string("runtime-file"), +}).pipe( + Command.withHandler(({ runtimeFile }) => + Effect.sync(() => { + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the shell snippet's loose parse. + const runtime = JSON.parse(NodeFS.readFileSync(runtimeFile, "utf8")) as { + pid?: unknown; + port?: unknown; + origin?: unknown; + }; + const pid = Number(runtime.pid); + const port = Number(runtime.port); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) { + process.exitCode = 1; + return; + } + const origin = new URL(String(runtime.origin ?? "")); + if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { + process.exitCode = 1; + return; + } + process.kill(pid, 0); + process.stdout.write(`${pid} ${port}`); + } catch { + process.exitCode = 1; + } + }), + ), +); + +export const sshHelperCommand = Command.make("__ssh-helper").pipe( + Command.unlisted, + Command.withSubcommands([pickPort, waitReady, runtimePort]), +); diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index deb7823f2dab..7a468c5e13f7 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -189,8 +189,8 @@ export const triageCommand = Command.make("triage", { buildTriageContext({ generatedAt: DateTime.formatIso(now), version, - releaseTag: version.includes("-nightly.") - ? `v${version} (nightly build; if this tag does not exist, clone main)` + releaseTag: /^[^-+]+-(?:nightly|preview)\./.test(version) + ? `v${version} (prerelease build; if this tag does not exist, clone main)` : `v${version}`, os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`, nodeVersion: process.version, diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts new file mode 100644 index 000000000000..02860f529440 --- /dev/null +++ b/apps/server/src/cli/uninstall.test.ts @@ -0,0 +1,37 @@ +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 Path from "effect/Path"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { findOwnedLauncher } from "./uninstall.ts"; + +it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { + it.effect("claims only a launcher that points into this home's runtime tree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" }); + const versionsDir = path.join(root, "runtime/versions"); + const exe = path.join(versionsDir, "1.0.0/t3"); + const otherExe = path.join(root, "other/runtime/versions/1.0.0/t3"); + const copy = path.join(root, "copy/t3"); + for (const file of [exe, otherExe, copy]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + const ours = path.join(root, "bin/t3"); + const theirs = path.join(root, "other/bin/t3"); + yield* fs.makeDirectory(path.dirname(ours), { recursive: true }); + yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); + yield* fs.symlink(exe, ours); + yield* fs.symlink(otherExe, theirs); + + assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); +}); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts new file mode 100644 index 000000000000..655f02b8c815 --- /dev/null +++ b/apps/server/src/cli/uninstall.ts @@ -0,0 +1,222 @@ +// @effect-diagnostics nodeBuiltinImport:off +// The Windows cleanup shell must outlive this process (it deletes the +// directory this executable runs from), which Effect's scoped ChildProcess +// cannot express: it kills the child when the scope closes. +import * as NodeChildProcess from "node:child_process"; + +import { + HostProcessEnvironment, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Console from "effect/Console"; +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 Schema from "effect/Schema"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; + +import * as BootService from "../cloud/bootService.ts"; +import { pinnedRuntimeVersionsDir } from "../cloud/pinnedRuntime.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { bootServiceLayer } from "./service.ts"; +import { findWindowsShim, launcherOwnsVersionsDir, resolveLauncherPath } from "./update.ts"; + +export class CliUninstallError extends Schema.TaggedError()( + "CliUninstallError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +/** + * What `t3 uninstall` would remove for one T3 home. Computed before anything + * is touched so the user sees the whole plan in one place. + */ +export interface UninstallPlan { + /** The background service serves this home and will be stopped and removed. */ + readonly service: boolean; + /** The `t3` launcher (symlink or `.cmd` shim) that points into this home's runtime tree. */ + readonly launcher: string | undefined; + /** `/runtime`, holding every downloaded version, when it exists. */ + readonly runtimeDir: string | undefined; + /** `/userdata`, which is never removed; shown so the user knows where it is. */ + readonly userdataDir: string; +} + +/** + * Finds the launcher this install left on PATH. Only a launcher that points + * into this home's `runtime/versions` is claimed: a plain copy of the + * executable, or a launcher for another home, is not ours to delete. + */ +export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(function* (input: { + readonly launchedAs: string | undefined; + readonly versionsDir: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + if (input.launchedAs === undefined) return undefined; + if (platform === "win32") { + const shimPath = yield* findWindowsShim(input.launchedAs); + if (shimPath === undefined) return undefined; + const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); + const target = Option.isSome(contents) ? /^"([^"]+)"/m.exec(contents.value)?.[1] : undefined; + return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target) + ? shimPath + : undefined; + } + const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); + if (Option.isNone(linkTarget)) return undefined; + const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value); + return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined; +}); + +const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: { + readonly baseDir: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* BootService.BootService; + const status = yield* service.status; + const servesThisHome = + status.installedBaseDir !== undefined && + path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); + const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); + const runtimeDir = path.dirname(versionsDir); + const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; + const plan: UninstallPlan = { + service: status.supported && status.installed && servesThisHome, + launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }), + runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false))) + ? runtimeDir + : undefined, + userdataDir: path.join(input.baseDir, "userdata"), + }; + return plan; +}); + +export const uninstallCommand = Command.make("uninstall", { + ...projectLocationFlags, + yes: Flag.boolean("yes").pipe( + Flag.withAlias("y"), + Flag.withDescription( + "Remove everything without asking. Required from a script, where there is no prompt.", + ), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription( + "Remove t3 from this machine: the background service, the launcher, and every downloaded version. Your projects and threads are kept.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* runUninstall({ baseDir: config.baseDir, assumeYes: flags.yes }).pipe( + Effect.provide(bootServiceLayer(config)), + ); + }), + ), +); + +const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { + readonly baseDir: string; + readonly assumeYes: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + const service = yield* BootService.BootService; + const plan = yield* planUninstall({ baseDir: input.baseDir }); + + if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) { + yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`); + if (!(yield* HostProcessIsExecutable)) { + yield* Console.log( + " This t3 runs from a Node script, so it was installed by npm or built from source. Remove it the same way (`npm uninstall -g t3`, or delete the checkout).", + ); + } + return; + } + + yield* Console.log("This will remove:"); + if (plan.service) yield* Console.log(" the background service (stopping it first)"); + if (plan.launcher !== undefined) yield* Console.log(` the launcher at ${plan.launcher}`); + if (plan.runtimeDir !== undefined) { + yield* Console.log(` every downloaded version under ${plan.runtimeDir}`); + } + yield* Console.log( + `Your projects, threads, and settings under ${plan.userdataDir} are kept. Delete that directory yourself if you want them gone too.`, + ); + + if (!input.assumeYes) { + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + return yield* new CliUninstallError({ + reason: + "Not a terminal, so nothing was removed. Rerun with --yes to confirm from a script.", + }); + } + const confirmed = yield* Prompt.run( + Prompt.confirm({ message: "Remove t3 from this machine?", initial: false }), + ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + if (!confirmed) { + yield* Console.log("Left as is."); + return; + } + } + + if (plan.service) { + yield* service.uninstall; + yield* Console.log("Removed the background service."); + } + if (plan.launcher !== undefined) { + yield* fs + .remove(plan.launcher, { force: true }) + .pipe( + Effect.mapError( + () => + new CliUninstallError({ reason: `Could not remove the launcher at ${plan.launcher}.` }), + ), + ); + yield* Console.log(`Removed ${plan.launcher}.`); + } + if (plan.runtimeDir !== undefined) { + // This process runs from inside runtimeDir. POSIX unlinks a running + // executable fine; Windows refuses, so the tree is removed after this + // process exits by a detached shell, and the user is told either way. + if (platform === "win32") { + const runtimeDir = plan.runtimeDir; + const comspec = environment["ComSpec"] ?? environment["COMSPEC"] ?? "cmd.exe"; + yield* Effect.try({ + try: () => { + const child = NodeChildProcess.spawn( + comspec, + ["/d", "/c", `ping -n 3 127.0.0.1 >nul & rmdir /s /q "${runtimeDir}"`], + { detached: true, stdio: "ignore", windowsHide: true }, + ); + child.unref(); + }, + catch: () => + new CliUninstallError({ + reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`, + }), + }); + yield* Console.log(`${runtimeDir} will be removed once t3 exits.`); + } else { + yield* fs + .remove(plan.runtimeDir, { recursive: true, force: true }) + .pipe( + Effect.mapError( + () => new CliUninstallError({ reason: `Could not remove ${plan.runtimeDir}.` }), + ), + ); + yield* Console.log(`Removed ${plan.runtimeDir}.`); + } + } + yield* Console.log(""); + yield* Console.log("t3 is uninstalled. Thanks for trying T3 Code."); +}); diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts new file mode 100644 index 000000000000..1a9c66428da9 --- /dev/null +++ b/apps/server/src/cli/update.test.ts @@ -0,0 +1,109 @@ +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import { + HostProcessEnvironment, + HostProcessInvokedAs, + HostProcessPlatform, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; + +import { repointLauncher, resolveLauncherPath } from "./update.ts"; + +it.layer(NodeServices.layer)("t3 update launcher", (it) => { + it.effect("repoints a symlink that lives in a runtime versions tree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const oldExe = path.join(root, "runtime/versions/1.0.0/t3"); + const newExe = path.join(root, "runtime/versions/2.0.0/t3"); + const launcher = path.join(root, "bin/t3"); + for (const file of [oldExe, newExe]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.symlink(oldExe, launcher); + + const repointed = yield* repointLauncher({ + launchedAs: launcher, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newExe, + }); + + assert.deepStrictEqual(Option.getOrUndefined(repointed), launcher); + assert.equal(yield* fs.readLink(launcher), newExe); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); + + it.effect("leaves a plain copy or a foreign symlink alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const newExe = path.join(root, "runtime/versions/2.0.0/t3"); + const copy = path.join(root, "copy/t3"); + const foreign = path.join(root, "foreign/t3"); + const elsewhere = path.join(root, "elsewhere/t3"); + // Another install's versions tree: same shape, different home. + const otherHome = path.join(root, "other/runtime/versions/1.0.0/t3"); + const otherLauncher = path.join(root, "other/bin/t3"); + for (const file of [newExe, copy, elsewhere, otherHome]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + yield* fs.makeDirectory(path.dirname(foreign), { recursive: true }); + yield* fs.symlink(elsewhere, foreign); + yield* fs.makeDirectory(path.dirname(otherLauncher), { recursive: true }); + yield* fs.symlink(otherHome, otherLauncher); + + for (const launchedAs of [copy, foreign, otherLauncher, undefined]) { + const repointed = yield* repointLauncher({ + launchedAs, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newExe, + }); + assert.equal(repointed._tag, "None", launchedAs ?? "undefined"); + } + assert.equal(yield* fs.readLink(foreign), elsewhere); + assert.equal(yield* fs.readLink(otherLauncher), otherHome); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); + + it.effect("finds the launcher a bare command name resolved to on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const launcher = path.join(root, "bin/t3"); + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.writeFileString(launcher, ""); + + const bare = yield* resolveLauncherPath.pipe( + Effect.provideService(HostProcessInvokedAs, "t3"), + Effect.provideService(HostProcessEnvironment, { + PATH: `${path.join(root, "missing")}:${path.join(root, "bin")}`, + }), + Effect.provideService(HostProcessWorkingDirectory, root), + ); + const relative = yield* resolveLauncherPath.pipe( + Effect.provideService(HostProcessInvokedAs, "./bin/t3"), + Effect.provideService(HostProcessEnvironment, { PATH: "" }), + Effect.provideService(HostProcessWorkingDirectory, root), + ); + const absent = yield* resolveLauncherPath.pipe( + Effect.provideService(HostProcessInvokedAs, "t3"), + Effect.provideService(HostProcessEnvironment, { PATH: path.join(root, "missing") }), + Effect.provideService(HostProcessWorkingDirectory, root), + ); + + assert.equal(bare, launcher); + assert.equal(relative, launcher); + assert.equal(absent, undefined); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); +}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts new file mode 100644 index 000000000000..950910c403f1 --- /dev/null +++ b/apps/server/src/cli/update.ts @@ -0,0 +1,588 @@ +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessInvokedAs, + HostProcessIsExecutable, + HostProcessPlatform, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import { + CLI_RELEASE_BASE_URL_ENV, + CLI_RELEASE_CHANNELS, + cliReleaseIndexPageUrl, + cliReleaseChannelOf, + newestCliReleaseVersion, + type CliReleaseChannel, +} from "@t3tools/shared/cliRelease"; +import * as Console from "effect/Console"; +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { + ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, + PinnedRuntimeInstallError, + pinnedRuntimePaths, +} from "../cloud/pinnedRuntime.ts"; +import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { bootServiceLayer } from "./service.ts"; + +export class CliUpdateError extends Schema.TaggedError()("CliUpdateError", { + reason: Schema.String, +}) { + override get message(): string { + return this.reason; + } +} + +const ReleaseIndex = Schema.Array( + Schema.Struct({ + tag_name: Schema.String, + draft: Schema.optional(Schema.Boolean), + }), +); +const decodeReleaseIndex = Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseIndex)); + +const RELEASE_INDEX_TIMEOUT = Duration.seconds(30); +// Enough to walk past a long run of nightlies without hammering the API when +// a channel genuinely has nothing published. +const RELEASE_INDEX_MAX_PAGES = 10; + +/** Asks GitHub for the newest published version on a channel, page by page. */ +const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( + channel: CliReleaseChannel, +) { + const httpClient = yield* HttpClient.HttpClient; + for (let page = 1; page <= RELEASE_INDEX_MAX_PAGES; page += 1) { + const body = yield* httpClient + .execute( + HttpClientRequest.get(cliReleaseIndexPageUrl(page)).pipe( + HttpClientRequest.setHeader("Accept", "application/vnd.github+json"), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + Effect.mapError(() => new CliUpdateError({ reason: "Could not list t3 releases." })), + Effect.timeoutOrElse({ + duration: RELEASE_INDEX_TIMEOUT, + orElse: () => + Effect.fail(new CliUpdateError({ reason: "Timed out listing t3 releases." })), + }), + ); + const releases = yield* decodeReleaseIndex(body).pipe( + Effect.mapError( + () => new CliUpdateError({ reason: "The t3 release index had an unexpected shape." }), + ), + ); + const version = newestCliReleaseVersion(releases, channel); + if (version !== undefined) return version; + if (releases.length === 0) break; + } + return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); +}); + +/** Whether a launcher target lives inside `/runtime/versions`. */ +export function launcherOwnsVersionsDir( + path: Path.Path, + versionsDir: string, + candidate: string, +): boolean { + const relative = path.relative(versionsDir, path.resolve(candidate)); + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +/** + * The launcher the install scripts leave behind: a symlink at `/t3` on + * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3` + * invocation is the new version. Only a launcher that already points into + * this home's `runtime/versions` tree is touched; a plain copy of the + * executable, or a launcher for some other install, is left alone. + */ +export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function* (input: { + /** Path the current process was started through, if known. */ + readonly launchedAs: string | undefined; + /** `/runtime/versions` of the home being updated. */ + readonly versionsDir: string; + readonly targetEntryPath: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + if (input.launchedAs === undefined) return Option.none(); + const ownsTarget = (candidate: string) => + launcherOwnsVersionsDir(path, input.versionsDir, candidate); + + if (platform === "win32") { + // The shim runs the executable by absolute path, so the executable sees + // itself as argv0; the shim is the `t3.cmd` next to it only when launched + // from an install script's bin directory. Find it by searching the + // directories that would resolve `t3` on this shell's PATH. + const shimPath = yield* findWindowsShim(input.launchedAs); + if (shimPath === undefined) return Option.none(); + const current = yield* fs.readFileString(shimPath).pipe(Effect.option); + const quoted = Option.isSome(current) ? /^"([^"]+)"/m.exec(current.value)?.[1] : undefined; + if (quoted === undefined || !ownsTarget(quoted)) return Option.none(); + yield* fs + .writeFileString(shimPath, `@echo off\r\n"${input.targetEntryPath}" %*`) + .pipe( + Effect.mapError( + () => new CliUpdateError({ reason: `Could not rewrite the t3 launcher at ${shimPath}.` }), + ), + ); + return Option.some(shimPath); + } + + const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); + if (Option.isNone(linkTarget)) return Option.none(); + const resolvedTarget = path.resolve(path.dirname(input.launchedAs), linkTarget.value); + if (!ownsTarget(resolvedTarget)) return Option.none(); + const tempLink = `${input.launchedAs}.${process.pid}.tmp`; + yield* fs.symlink(input.targetEntryPath, tempLink).pipe( + Effect.andThen(fs.rename(tempLink, input.launchedAs)), + Effect.mapError( + () => + new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }), + ), + ); + return Option.some(input.launchedAs); +}); + +/** + * The path the executable was started through. Node keeps the shell's + * spelling in argv0: a launcher symlink or `./t3` resolves against the + * working directory, while a bare `t3` was found on PATH and has to be + * looked up there again, or the launcher symlink is never seen. + */ +export const resolveLauncherPath = Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const invokedAs = yield* HostProcessInvokedAs; + const cwd = yield* HostProcessWorkingDirectory; + const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; + if (invokedAs.includes("/") || invokedAs.includes("\\")) { + return path.resolve(cwd, invokedAs); + } + const delimiter = platform === "win32" ? ";" : ":"; + for (const directory of (environment["PATH"] ?? "").split(delimiter)) { + if (directory.length === 0) continue; + const candidate = path.join(directory, invokedAs); + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return candidate; + } + } + return undefined; +}); + +/** + * On Windows a `.cmd` shim is what PATH resolves, but the executable it runs + * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the + * running executable; that is the launcher the install script wrote. + */ +export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( + executablePath: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const candidates = [ + ...(environment["T3CODE_INSTALL_BIN_DIR"] ? [environment["T3CODE_INSTALL_BIN_DIR"]] : []), + ...(environment["PATH"] ?? environment["Path"] ?? "").split(";"), + ].filter((entry) => entry.trim().length > 0); + for (const directory of candidates) { + const shimPath = path.join(directory, "t3.cmd"); + const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); + if (Option.isNone(contents)) continue; + const target = /^"([^"]+)"/m.exec(contents.value)?.[1]; + if ( + target !== undefined && + path.resolve(target).toLowerCase() === path.resolve(executablePath).toLowerCase() + ) { + return shimPath; + } + } + return undefined; +}); + +const updateFlags = { + ...projectLocationFlags, + channel: Flag.choice("channel", CLI_RELEASE_CHANNELS).pipe( + Flag.withDescription( + "Release channel to follow. Defaults to the channel this t3 was published on.", + ), + Flag.optional, + ), + allowDowngrade: Flag.boolean("allow-downgrade").pipe( + Flag.withDescription("Allow moving to an older version than the one running."), + Flag.withDefault(false), + ), + yes: Flag.boolean("yes").pipe( + Flag.withAlias("y"), + Flag.withDescription( + "Restart the background service without asking. Required to restart it from a script, where there is no prompt.", + ), + Flag.withDefault(false), + ), +}; + +const versionArgument = Argument.string("version").pipe( + Argument.withDescription( + "Exact version to install. Defaults to the newest release on the channel.", + ), + Argument.optional, +); + +export const updateCommand = Command.make("update", { + ...updateFlags, + version: versionArgument, +}).pipe( + Command.withDescription( + "Download a newer t3 and switch this machine to it, including the background service when one is installed.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* runUpdate({ + baseDir: config.baseDir, + logsDir: config.logsDir, + serverRuntimeStatePath: config.serverRuntimeStatePath, + channel: Option.getOrUndefined(flags.channel), + requestedVersion: Option.getOrUndefined(flags.version), + allowDowngrade: flags.allowDowngrade, + assumeYes: flags.yes, + }).pipe( + Effect.provide( + Layer.mergeAll(bootServiceLayer(config), ProcessRunner.layer, FetchHttpClient.layer), + ), + ); + }), + ), +); + +/** + * A `t3 serve` or `t3` someone started by hand, as opposed to the one the + * background service supervises. The server records its pid on startup; a + * stale file from a crashed server is ignored by checking the pid is alive. + * + * Servers from before `serviceManaged` was recorded cannot be told apart by + * the file alone, so the launcher-supervised case is also recognised by + * lineage: a service server's parent is the launcher, and on Linux that + * launcher runs inside the unit's cgroup. + */ +const findForegroundServer = Effect.fn("cli.update.find_foreground_server")(function* (input: { + readonly serverRuntimeStatePath: string; + readonly serviceInstalled: boolean; +}) { + const state = yield* readPersistedServerRuntimeState(input.serverRuntimeStatePath); + if (Option.isNone(state) || state.value.serviceManaged || !isProcessAlive(state.value.pid)) { + return undefined; + } + if (input.serviceInstalled && (yield* belongsToBootService(state.value.pid))) return undefined; + return state.value; +}); + +const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(function* ( + pid: number, +) { + const platform = yield* HostProcessPlatform; + const fs = yield* FileSystem.FileSystem; + const runner = yield* ProcessRunner.ProcessRunner; + if (platform === "linux") { + const cgroup = yield* fs.readFileString(`/proc/${pid}/cgroup`).pipe(Effect.option); + return Option.isSome(cgroup) && cgroup.value.includes("/t3code.service"); + } + if (platform === "darwin") { + // The service server's parent is the launcher process. + const parent = yield* runner + .run({ + command: "ps", + args: ["-o", "ppid=", "-p", String(pid)], + timeout: Duration.seconds(5), + }) + .pipe(Effect.option); + const ppid = Option.isSome(parent) && parent.value.code === 0 ? parent.value.stdout.trim() : ""; + if (!/^\d+$/.test(ppid)) return false; + const command = yield* runner + .run({ command: "ps", args: ["-o", "command=", "-p", ppid], timeout: Duration.seconds(5) }) + .pipe( + Effect.map((result) => (result.code === 0 ? result.stdout : "")), + Effect.orElseSucceed(() => ""), + ); + return /__service-launcher/.test(command); + } + return false; +}); + +const runUpdate = Effect.fn("cli.update.run")(function* (input: { + readonly baseDir: string; + readonly logsDir: string; + readonly serverRuntimeStatePath: string; + readonly channel: CliReleaseChannel | undefined; + readonly requestedVersion: string | undefined; + readonly allowDowngrade: boolean; + readonly assumeYes: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; + const environment = yield* HostProcessEnvironment; + const httpClient = yield* HttpClient.HttpClient; + const service = yield* BootService.BootService; + + const currentVersion = packageJson.version; + const channel = input.channel ?? cliReleaseChannelOf(currentVersion); + if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) { + return yield* new CliUpdateError({ + reason: `'${input.requestedVersion}' is not an exact t3 version.`, + }); + } + const targetVersion = input.requestedVersion ?? (yield* resolveNewestVersion(channel)); + const targetChannel = cliReleaseChannelOf(targetVersion); + + // Preview is a maintainers' dogfooding train: it is cut by hand from + // unmerged branches, receives no fixes, and is never offered to anyone. + // Reaching it from stable or nightly takes an explicit ask and an explicit + // acknowledgement; the flag alone is not enough from a script. + const currentChannel = cliReleaseChannelOf(currentVersion); + if (targetChannel === "preview" && currentChannel !== "preview") { + yield* Console.log( + [ + `t3@${targetVersion} is a preview build.`, + " Preview builds are cut by maintainers from unreleased branches to exercise the release", + " pipeline. They can be broken, receive no fixes, and are never offered as updates; you", + ` will have to switch back to ${currentChannel} yourself with \`t3 update --channel ${currentChannel} --allow-downgrade\`.`, + ].join("\n"), + ); + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + return yield* new CliUpdateError({ + reason: + "Refusing to install a preview build without confirmation. Run this from a terminal to confirm, or pass --channel preview from an interactive shell.", + }); + } + const confirmed = yield* Prompt.run( + Prompt.confirm({ message: "Install the preview build anyway?", initial: false }), + ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + if (!confirmed) { + yield* Console.log("Left as is."); + return; + } + } + + // Work out everything that will be touched before touching anything, so the + // user sees one plan and one question rather than a surprise restart. + const status = yield* service.status; + // The unit name is per user, not per T3 home. Only touch the service when it + // serves the home this update targets; otherwise it belongs to another + // install on this machine and restarting it would take that server down. + const servesThisHome = + status.installedBaseDir !== undefined && + path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); + const serviceInstalled = status.supported && status.installed && servesThisHome; + const foreground = yield* findForegroundServer({ + serverRuntimeStatePath: input.serverRuntimeStatePath, + serviceInstalled, + }); + // What this machine runs is the executable behind the launcher and, when a + // service is installed for this home, the version that service runs. Either + // being stale is an update to do, and the newest of the two is what the + // downgrade check protects. + const serviceVersion = serviceInstalled ? status.installedVersion : undefined; + const executableCurrent = targetVersion === currentVersion; + // A service whose recorded version is missing or unreadable is not known + // to be current, so it gets the update rather than being skipped. Nor is + // one on the right version that is stopped, disabled, or still running the + // version before it (an earlier update where the restart was declined): + // `status.current` covers all of that when the target is this executable, + // and the problem list is what can be judged for any other target. + const restartPending = status.problems?.includes("restart-pending") === true; + const serviceCurrent = + !serviceInstalled || + (serviceVersion === targetVersion && + (executableCurrent ? status.current : (status.problems ?? []).length === 0)); + const newestInstalled = + serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0 + ? serviceVersion + : currentVersion; + + if (executableCurrent && serviceCurrent) { + yield* Console.log( + serviceVersion !== undefined + ? `t3 and its background service are already on ${targetVersion} (${targetChannel}).` + : `t3 is already on ${targetVersion} (${targetChannel}).`, + ); + return; + } + if (!input.allowDowngrade && compareExactServiceVersions(targetVersion, newestInstalled) < 0) { + return yield* new CliUpdateError({ + reason: `t3@${targetVersion} is older than the installed ${newestInstalled}. Pass --allow-downgrade to install it anyway.`, + }); + } + + const alreadyOnDisk = yield* fs + .readFileString(pinnedRuntimePaths(path, input.baseDir, targetVersion, platform).sentinelPath) + .pipe( + Effect.map((sentinel) => sentinel.trim() === targetVersion), + Effect.orElseSucceed(() => false), + ); + + yield* Console.log( + executableCurrent && restartPending + ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` + : executableCurrent + ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` + : alreadyOnDisk + ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` + : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, + ); + let restartService = false; + if (serviceInstalled && !serviceCurrent) { + yield* Console.log( + " A background service is installed for this T3 home. Restarting it interrupts anything running in it: agent turns, terminals, remote clients.", + ); + if (input.assumeYes) { + restartService = true; + } else if (process.stdin.isTTY && process.stdout.isTTY) { + restartService = yield* Prompt.run( + Prompt.confirm({ + message: "Restart the background service once the download is verified?", + initial: true, + }), + ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + } else { + yield* Console.log( + " Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run `t3 service restart` later.", + ); + } + } + + const runtime = yield* ensurePinnedRuntimeInstalled({ + baseDir: input.baseDir, + version: targetVersion, + fs, + path, + runner, + httpClient, + platform, + arch, + releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined, + validate: (paths) => + runner + .run({ + command: pinnedRuntimeCommand(paths).command, + args: [...pinnedRuntimeCommand(paths).args, "--version"], + timeout: Duration.seconds(30), + }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "verifying the downloaded t3", cause }), + ), + Effect.flatMap((result) => + result.code === 0 && /\bv(\S+)\s*$/.exec(result.stdout)?.[1] === targetVersion + ? Effect.void + : Effect.fail( + new PinnedRuntimeInstallError({ + step: "verifying the downloaded t3", + exitCode: Number(result.code), + }), + ), + ), + ), + }).pipe( + Effect.catchIf( + (error): error is PinnedRuntimeInstallError => + error._tag === "PinnedRuntimeInstallError" && + error.step.startsWith("downloading the t3 release checksums") && + String(error.cause).includes("404"), + () => + Effect.fail( + new CliUpdateError({ + reason: `No release archive was published for t3@${targetVersion}.`, + }), + ), + ), + ); + + const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; + const repointed = yield* repointLauncher({ + launchedAs, + versionsDir: path.dirname(runtime.versionDir), + targetEntryPath: runtime.entryPath, + }); + + // The service switch runs in this process against the target version: the + // downloaded runtime has already proven it runs (the `--version` check + // above), and doing it here rather than through the target's own CLI means + // a downgrade to a version without today's commands still works. The unit + // is rewritten either way so a later `t3 service restart` lands on the new + // version; only the restart itself waits for the user's answer. + let serviceUpdated = false; + if (serviceInstalled && !serviceCurrent) { + yield* BootService.BootService.pipe( + Effect.flatMap((target) => + target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), + ), + Effect.provide( + BootService.layer({ + baseDir: input.baseDir, + logsDir: input.logsDir, + cliVersion: targetVersion, + }), + ), + Effect.mapError( + (error) => + new CliUpdateError({ + reason: `t3@${targetVersion} is installed but the background service could not be ${restartService ? "updated" : "pointed at it"}: ${error.message}`, + }), + ), + ); + serviceUpdated = restartService; + } + + yield* Console.log(""); + yield* Console.log(`t3 ${targetVersion} is installed at ${runtime.entryPath}`); + if (Option.isSome(repointed)) { + yield* Console.log(` ${repointed.value} now runs ${targetVersion}`); + } else { + yield* Console.log(` Run it as ${runtime.entryPath}, or point your \`t3\` launcher at it.`); + } + if (serviceUpdated) { + yield* Console.log(` Background service restarted on ${targetVersion}`); + } else if (serviceInstalled && serviceCurrent) { + yield* Console.log(` Background service already on ${targetVersion}`); + } else if (serviceInstalled) { + yield* Console.log( + ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service restart\` when you are ready to switch it to ${targetVersion}.`, + ); + } else if (status.installed && !servesThisHome) { + yield* Console.log( + ` The background service serves ${status.installedBaseDir ?? "another T3 home"} and was left unchanged.`, + ); + } + if (foreground !== undefined) { + yield* Console.log( + ` A server started by hand is still running at ${foreground.origin} (pid ${foreground.pid}). Stop it and start it again to pick up ${targetVersion}.`, + ); + } +}); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 688617440500..25609853d2d3 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { - HostProcessArguments, HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, @@ -12,6 +11,7 @@ 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 * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -20,38 +20,63 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; import { parseServiceState, SERVICE_LAUNCHER_PROTOCOL, + SERVICE_RESTART_PENDING_FILE, serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; -it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { - const unit = BootService.renderBootServiceUnit({ - nodePath: "/usr/bin/node", - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); +const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3"; +const linuxPlan = { + program: [linuxRuntime, "__service-launcher"], + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", +}; + +it("runs the pinned runtime's own executable as the systemd launcher", () => { + const unit = BootService.renderBootServiceUnit(linuxPlan); - expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); + expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`); expect(unit).toContain("KillMode=mixed"); - expect(unit).not.toContain("versions/1.2.3"); + expect(unit).not.toContain("node"); }); -it("survives the kernel OOM-killing a greedy agent child", () => { - const unit = BootService.renderBootServiceUnit({ - nodePath: "/usr/bin/node", - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", +it("reads the served T3 home back out of a rendered unit or plist", () => { + const plan = (baseDir: string) => ({ + program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], + baseDir, + logPath: `${baseDir}/userdata/logs/boot-service.log`, unitPath: "/home/theo/.config/systemd/user/t3code.service", }); + expect( + BootService.bootServiceBaseDirOf(BootService.renderBootServiceUnit(plan("/home/theo/.t3"))), + ).toBe("/home/theo/.t3"); + // Spaces and specifiers are quoted and escaped on the way in. + expect( + BootService.bootServiceBaseDirOf( + BootService.renderBootServiceUnit(plan("/home/theo/T3 Data/100%")), + ), + ).toBe("/home/theo/T3 Data/100%"); + expect( + BootService.bootServiceBaseDirOf( + BootService.renderBootServicePlist(plan("/Users/theo/a&b"), { + homeDir: "/Users/theo", + environmentPath: "/usr/bin", + }), + ), + ).toBe("/Users/theo/a&b"); + expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); +}); + +it("survives the kernel OOM-killing a greedy agent child", () => { + const unit = BootService.renderBootServiceUnit(linuxPlan); + expect(unit).toContain("OOMPolicy=continue"); }); +const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3"; const macPlan = { - nodePath: "/opt/homebrew/bin/node", - launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", + program: [macRuntime, "__service-launcher"], baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", @@ -60,12 +85,13 @@ const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; -it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { +it("runs the pinned runtime's own executable as the launch agent", () => { const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - expect(plist).toContain("/opt/homebrew/bin/node"); - expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); - expect(plist).not.toContain("versions/1.2.3"); + expect(plist).toContain( + ` \n ${macRuntime}\n __service-launcher\n `, + ); + expect(plist).not.toContain("node"); }); it("preserves the installer's provider search path in the launch agent", () => { @@ -106,23 +132,18 @@ it("escapes XML in host paths", () => { const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", - usePinnedLauncher = false, installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); const baseDir = path.join(home, ".t3"); - const sourceLauncher = path.join(home, "service-launcher.mjs"); const statePath = path.join(baseDir, "runtime", "service-state.json"); - yield* fs.writeFileString(sourceLauncher, "export {};\n"); - const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3"); + // A complete pinned runtime is already present, so install only validates + // it and never downloads a release archive. + const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); - yield* fs.writeFileString(runtime.entryPath, "export {};\n"); - yield* fs.writeFileString( - path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"), - "export const source = 'pinned runtime';\n", - ); + yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n"); yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; @@ -160,8 +181,10 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( } return { stdout: - input.args[1] === "--version" - ? "t3 v1.2.3\n" + input.args[0] === "--version" + ? // The runtime under test reports the version of the directory it + // was launched from, like the real executable. + `t3 v${/versions\/([^/]+)\//.exec(input.command)?.[1] ?? "1.2.3"}\n` : input.command === "loginctl" && input.args[0] === "show-user" ? `${control.linger}\n` : input.args[1] === "is-enabled" @@ -181,26 +204,43 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( }; }), }); - const makeService = (environmentPath = installerPath) => - BootService.make({ - baseDir, - logsDir: path.join(baseDir, "userdata", "logs"), - cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, + const makeService = ( + environmentPath: string | undefined = installerPath, + cliVersion = "1.2.3", + serviceBaseDir = baseDir, + ) => + Effect.gen(function* () { + // Every version the tests install is present and verified on disk, so + // install never downloads. + const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform); + yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true }); + yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n"); + yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`); + return yield* BootService.make({ + baseDir: serviceBaseDir, + logsDir: path.join(serviceBaseDir, "userdata", "logs"), + cliVersion, + host: { execPath: "/usr/bin/t3" }, + }); }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provide( Layer.mergeAll( Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("no release download expected")), + ), ConfigProvider.layer( ConfigProvider.fromEnv({ - env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, + env: { + HOME: home, + ...(environmentPath === undefined || environmentPath === "" + ? {} + : { PATH: environmentPath }), + }, }), ), ), @@ -231,9 +271,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(error.message).toContain("last login session ends"); expect(yield* fs.exists(before.unitPath)).toBe(false); expect(yield* fs.exists(statePath)).toBe(false); - expect( - commands.some((command) => command.startsWith("npm ") || command.includes("--version")), - ).toBe(false); + expect(commands.some((command) => command.includes("--version"))).toBe(false); expect( commands.some( (command) => command.includes("daemon-reload") || command.includes("restart"), @@ -306,14 +344,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); + const { service, fs, statePath, timeouts, runtime } = yield* makeHarness(); const plan = yield* service.install(); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + `ExecStart=${runtime.entryPath} __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", @@ -334,7 +375,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).current).toBe(false); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); // The stop can block up to systemd's 90s TimeoutStopSec; the runner's // 60s default would cancel it mid-shutdown. expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( @@ -388,7 +428,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs, statePath, commands, control } = yield* makeHarness(platform); const plan = yield* service.install(); - const launcher = yield* fs.readFileString(plan.launcherPath); const unit = yield* fs.readFileString(plan.unitPath); control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; commands.length = 0; @@ -401,7 +440,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { targetVersion: "1.2.3", }); expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); - expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); expect( commands.filter( @@ -451,14 +489,136 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("copies the launcher from the prepared pinned runtime", () => + it.effect("install with start=false rewrites the files and marks a restart pending", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("linux", true); - const plan = yield* service.install(); + const { service, fs, statePath, commands, makeService } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; - expect(yield* fs.readFileString(plan.launcherPath)).toBe( - "export const source = 'pinned runtime';\n", - ); + const newer = yield* makeService(undefined, "1.2.4"); + const plan = yield* newer.install({ start: false }); + + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.4", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([]); + // The files say 1.2.4 but the process is still 1.2.3: not current, and + // the reason is named so `t3 service status` can point at restart. + const status = yield* newer.status; + expect(status.current).toBe(false); + expect(status.problems).toContain("restart-pending"); + + commands.length = 0; + expect(yield* newer.restart).toBe(true); + expect((yield* newer.status).problems).not.toContain("restart-pending"); + expect((yield* newer.status).current).toBe(true); + }), + ); + + it.effect("install with start=false keeps the marker when a later write fails", () => + Effect.gen(function* () { + const { service, fs, statePath, makeService } = yield* makeHarness(); + const path = yield* Path.Path; + yield* service.install(); + const newer = yield* makeService(undefined, "1.2.4"); + // A non-empty directory in the unit's place: it still counts as an + // installed unit, and the rename that writes the new unit fails. + const unitPath = (yield* service.status).unitPath; + yield* fs.remove(unitPath); + yield* fs.makeDirectory(unitPath); + yield* fs.writeFileString(path.join(unitPath, "occupied"), ""); + + const error = yield* newer.install({ start: false }).pipe(Effect.flip); + expect(error._tag).toBe("BootServiceInstallError"); + expect( + yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), + ).toBe(true); + }), + ); + + it.effect("install with start=false refuses while a remote update is pending", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. + const pendingState = JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + update: { + id: "u", + fromVersion: "1.2.3", + targetVersion: "1.2.4", + dbPath: "/tmp/state.sqlite", + status: "pending", + }, + }); + yield* fs.writeFileString(statePath, pendingState); + + const error = yield* service.install({ start: false }).pipe(Effect.flip); + expect(error._tag).toBe("BootServiceUpdatePendingError"); + expect(yield* fs.readFileString(statePath)).toBe(pendingState); + }), + ); + + it.effect("restart stops and starts an installed service, and is a no-op otherwise", () => + Effect.gen(function* () { + const { service, commands } = yield* makeHarness(); + expect(yield* service.restart).toBe(false); + yield* service.install(); + commands.length = 0; + + expect(yield* service.restart).toBe(true); + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + "systemctl --user restart t3code.service", + ]); + }), + ); + + it.effect("restart leaves a service that serves another T3 home alone", () => + Effect.gen(function* () { + const { service, fs, commands, makeService } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; + const path = yield* Path.Path; + const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" }); + + const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3")); + expect(yield* other.restart).toBe(false); + expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]); + }), + ); + + it.effect("restart brings the service back when activation fails", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; + control.failCommand = "systemctl --user daemon-reload"; + + const error = yield* service.restart.pipe(Effect.flip); + expect(error._tag).toBe("BootServiceCommandError"); + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user daemon-reload", + "systemctl --user restart t3code.service", + ]); }), ); @@ -528,7 +688,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); + const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); const path = yield* Path.Path; const plan = yield* service.install(); @@ -544,14 +704,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` ${runtime.entryPath}\n __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", }); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); // A bootout can block up to the plist's 90s ExitTimeOut; the runner's // 60s default would cancel it and let bootstrap race a loaded job. @@ -582,7 +743,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, ""); + const { service, fs } = yield* makeHarness("darwin", ""); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -594,7 +755,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("adds missing provider directories to a minimal installer PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin"); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -618,7 +779,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs } = yield* makeHarness( "darwin", - false, "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", ); const plan = yield* service.install(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 22f383701e8e..24495c4c2e8e 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,4 +1,5 @@ import { + HostProcessArchitecture, HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, @@ -12,17 +13,21 @@ 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 { HttpClient } from "effect/unstable/http"; import * as Schema from "effect/Schema"; +import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; + import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { - SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, + SERVICE_RESTART_PENDING_FILE, SERVICE_STATE_FILE, compareExactServiceVersions, parseServiceState, @@ -51,9 +56,36 @@ function quoteSystemdValue(value: string): string { : escaped; } +/** + * Reads `T3CODE_HOME` back out of a rendered unit or plist. Only values this + * file writes are expected, so a quoted systemd value is unquoted and + * unescaped the same way `quoteSystemdValue` produced it. + */ +export function bootServiceBaseDirOf(contents: string): string | undefined { + const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; + if (systemd !== undefined) { + const raw = systemd.trim(); + const unquoted = + raw.startsWith('"') && raw.endsWith('"') + ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") + : raw; + return unquoted.replaceAll("%%", "%"); + } + const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; + if (plist !== undefined) { + return plist.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + } + return undefined; +} + export interface BootServicePlan { - readonly nodePath: string; - readonly launcherPath: string; + /** + * What the service manager executes. npm-distributed runtimes run the + * standalone launcher script with the installing Node; archive-distributed + * runtimes run their own executable, which hosts the launcher as a hidden + * subcommand so the machine never needs Node. + */ + readonly program: ReadonlyArray; readonly baseDir: string; readonly logPath: string; readonly unitPath: string; @@ -73,7 +105,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, - `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, + `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", @@ -124,8 +156,7 @@ export function renderBootServicePlist( ` ${BOOT_SERVICE_LAUNCHD_LABEL}`, ` ProgramArguments`, ` `, - ` ${escapeXmlText(plan.nodePath)}`, - ` ${escapeXmlText(plan.launcherPath)}`, + ...plan.program.map((argument) => ` ${escapeXmlText(argument)}`), ` `, ` EnvironmentVariables`, ` `, @@ -414,6 +445,7 @@ const BootServiceProblem = Schema.Literals([ "linger-disabled", "service-disabled", "service-stopped", + "restart-pending", ]); type BootServiceProblem = typeof BootServiceProblem.Type; @@ -427,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string { case "linger-disabled": return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.'; case "service-disabled": - return "The service is not enabled to start automatically. Run `t3 service update` to repair it."; + return "The service is not enabled to start automatically. Run `t3 service install` to repair it."; case "service-stopped": - return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`."; + return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service install`."; + case "restart-pending": + return "A newer version is installed but the service is still running the previous one. Run `t3 service restart` to switch."; } } @@ -476,6 +510,13 @@ export interface BootServiceStatus { readonly installed: boolean; readonly current: boolean; readonly installedVersion?: string; + /** + * The T3 home the installed unit serves. The unit name is fixed per user, + * so a caller working against another base dir must not treat this service + * as its own; `t3 update --base-dir` learned that by restarting the live + * server of the machine it ran on. + */ + readonly installedBaseDir?: string; readonly problems?: ReadonlyArray; readonly unitPath: string; readonly logPath: string; @@ -486,7 +527,20 @@ export class BootService extends Context.Service< { readonly install: (options?: { readonly allowDowngrade?: boolean; + /** + * Write the unit for this version but leave the service on whatever it + * is running now. `t3 update` uses this when the user declines the + * restart, so a later `t3 service restart` lands on the new version. + */ + readonly start?: boolean; }) => Effect.Effect; + /** + * Stop and start the installed service on the version its unit names. + * Only when the unit serves this base dir: the unit name is per user, so + * another home's service is left alone. Resolves false when nothing was + * restarted. + */ + readonly restart: Effect.Effect; readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } @@ -494,7 +548,6 @@ export class BootService extends Context.Service< export interface BootServiceHost { readonly execPath: string; - readonly launcherSourcePath?: string; } export const make = Effect.fn("cloud.boot_service.make")(function* (input: { @@ -505,7 +558,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }) { const hostExecPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; const uid = yield* HostProcessUserId; + const httpClient = yield* HttpClient.HttpClient; + const releaseBaseUrl = Option.getOrUndefined( + yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), + ); const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; @@ -542,12 +600,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); - const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); - const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); - const launcherSourcePath = - host.launcherSourcePath ?? - path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); + const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { @@ -567,9 +622,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // The executable hosts the launcher as a hidden subcommand of itself, so + // the unit runs the pinned runtime directly. const plan: BootServicePlan = { - nodePath: host.execPath, - launcherPath, + program: [runtimePaths.entryPath, "__service-launcher"], baseDir: input.baseDir, logPath, unitPath, @@ -690,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const install = Effect.fn("cloud.boot_service.install")(function* (options?: { readonly allowDowngrade?: boolean; + readonly start?: boolean; }) { const manager = yield* requireManager; yield* fs @@ -708,11 +765,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs, path, runner, + httpClient, + platform, + arch, + releaseBaseUrl, validate: (runtime) => runner .run({ - command: host.execPath, - args: [runtime.entryPath, "--version"], + command: pinnedRuntimeCommand(runtime).command, + args: [...pinnedRuntimeCommand(runtime).args, "--version"], timeout: Duration.seconds(30), }) .pipe( @@ -750,14 +811,18 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : new BootServiceInstallError({ cause: error }), ), ); - const launcherSource = yield* fs - .readFileString(launcherSourcePath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (installed) { + // With start=false the service keeps running while its files change. The + // launcher reads the state file once at startup and the unit only matters + // on the next start, so that is safe as long as the launcher is not in + // the middle of a remote update, which is the one time it writes the + // state file itself. That case is refused below, before anything is + // written, from the same read the downgrade check uses; the stop that + // normally serialises against the launcher is skipped on purpose. + const start = options?.start !== false; + if (installed && start) { yield* runSteps(manager.stop); } @@ -786,7 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - yield* writeDurably(launcherPath, launcherSource); + if (!start && installed) { + // Written first: once the files below name the new version, the + // running service is behind them, and a failure between the two + // writes must not leave it looking current. The launcher removes the + // marker when it starts, `restart` and a started install do too. + yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); + } yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. @@ -799,17 +870,61 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { 2, )}\n`, ); + if (!start && installed) { + // The launcher only writes this file while a remote update is in + // flight. One that began after the check above lands either before + // this write (then the launcher's copy in memory is what it keeps + // acting on, and its next write puts its own outcome back) or after + // it, which this read catches: the file no longer says what was just + // written, so stop here before repointing the unit. + const written = yield* fs.readFileString(statePath); + if (serviceStateActiveVersion(written) !== input.cliVersion) { + return yield* new BootServiceUpdatePendingError(); + } + } yield* writeDurably(unitPath, manager.render(plan)); - yield* runSteps(manager.activate); + if (start) { + yield* runSteps(manager.activate); + yield* fs.remove(restartPendingPath, { force: true }); + } }).pipe( + Effect.mapError((cause) => + cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, + ), Effect.tapError(() => - installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, + installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, ), ); return plan; }); + const restart: BootService["Service"]["restart"] = Effect.gen(function* () { + const manager = yield* requireManager; + const unit = yield* fs.readFileString(unitPath).pipe(Effect.option); + if (Option.isNone(unit)) return false; + const installedBaseDir = bootServiceBaseDirOf(unit.value); + if ( + installedBaseDir === undefined || + path.resolve(installedBaseDir) !== path.resolve(input.baseDir) + ) { + return false; + } + yield* runSteps(manager.stop); + yield* runSteps(manager.activate).pipe( + // Same recovery as a failed repair: a service that was running should + // not be left stopped because daemon-reload or enable failed. + Effect.tapError(() => runSteps(manager.restart).pipe(Effect.ignore)), + ); + yield* fs.remove(restartPendingPath, { force: true }); + return true; + }).pipe( + Effect.mapError((cause) => + cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, + ), + Effect.withSpan("cloud.boot_service.restart"), + ); + const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { const manager = yield* requireManager; if ( @@ -833,32 +948,33 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!(yield* fs.exists(unitPath))) { return { supported: true, installed: false, current: false, unitPath, logPath }; } - const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = - yield* Effect.all([ - fs.readFileString(unitPath), - fs.exists(launcherPath), - fs.exists(runtimePaths.entryPath), - fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), - fs.readFileString(statePath).pipe(Effect.option), - ]); + const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ + fs.readFileString(unitPath), + fs.exists(runtimePaths.entryPath), + fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), + fs.readFileString(statePath).pipe(Effect.option), + ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; const installedVersion = Option.isSome(stateText) ? serviceStateActiveVersion(stateText.value) : undefined; + const installedBaseDir = bootServiceBaseDirOf(unit); const normalizeUnit = (contents: string) => detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") : contents; - const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : []; + const problems: BootServiceProblem[] = + detectedManager.kind === "systemd" ? [...(yield* readSystemdProblems(true))] : []; + if (yield* fs.exists(restartPendingPath)) problems.push("restart-pending"); return { supported: true, installed: true, ...(installedVersion === undefined ? {} : { installedVersion }), + ...(installedBaseDir === undefined ? {} : { installedBaseDir }), problems, current: problems.length === 0 && normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && - launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && runtimeSentinel.value.trim() === input.cliVersion && @@ -872,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { Effect.withSpan("cloud.boot_service.status"), ); - return BootService.of({ install, uninstall, status }); + return BootService.of({ install, restart, uninstall, status }); }); export const layer = (input: { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index a0ca9e5f0fa0..e4b16b8f7190 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,26 +5,49 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; -const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => +// Every install fetches the release archive, checks it against SHA256SUMS, +// and unpacks it with tar. The fake client serves both files; the fake runner +// stands in for tar and drops the executable where extraction would. +const version = "1.2.3"; +const archiveName = `t3-${version}-linux-x64.tar.gz`; +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const archiveHex = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( + Effect.map((digest) => + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); +const validChecksums = archiveHex(archiveBytes).pipe( + Effect.map((hex) => `${hex} ${archiveName}\n`), +); +const releaseHttpClient = (checksums: string, requests: string[] = []) => + HttpClient.make((request) => { + requests.push(request.url); + const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); + }); +const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) => ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - const prefixIndex = input.args.indexOf("--prefix"); - const stagingDir = input.args[prefixIndex + 1]; - if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix"); - const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + commands.push(input.command); + const targetIndex = input.args.indexOf("-C"); + const stagingDir = input.args[targetIndex + 1]; + if (input.command !== "tar" || stagingDir === undefined) { + return yield* Effect.die(`unexpected command ${input.command}`); + } + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -39,81 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { - it.effect("installs through pnpm when its Node runtime has no npm executable", () => + it.effect("installs the verified release archive as the runtime executable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); - const commands: Array = []; - const install = successfulRunner(fs, path); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); + const requests: string[] = []; + const commands: string[] = []; const paths = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input); - return input.command === "npm" - ? Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: "npm", - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - }), - }), - ) - : install.run(input); - }, - }), + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums, requests), + releaseBaseUrl: "https://releases.example/download", + runner: extractingRunner(fs, path, commands), validate: (staging) => fs.exists(staging.entryPath).pipe( Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), Effect.orDie, ), }); - assert.deepEqual( - commands.map((command) => command.command), - ["npm", "pnpm"], - ); - assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); - assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); + assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] }); + assert.deepEqual(requests, [ + `https://releases.example/download/v${version}/SHA256SUMS`, + `https://releases.example/download/v${version}/${archiveName}`, + ]); + assert.deepEqual(commands, ["tar"]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); + assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); }), ); - it.effect("does not try a different installer for npm permission failures", () => + it.effect("refuses an archive whose checksum does not match the release", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); const commands: string[] = []; - yield* ensurePinnedRuntimeInstalled({ + const error = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input.command); - return Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - }), - }), - ); - }, - }), - validate: () => Effect.die("must not validate a failed install"), + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`), + runner: extractingRunner(fs, path, commands), + validate: () => Effect.die("must not validate an unverified archive"), }).pipe(Effect.flip); - assert.deepEqual(commands, ["npm"]); + assert.instanceOf(error, PinnedRuntimeInstallError); + assert.equal(error.step, "verifying the t3 release archive checksum"); + assert.deepEqual(commands, []); + assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); }), ); @@ -122,15 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); let validatedDirectory = ""; const installed = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, - runner: successfulRunner(fs, path), + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: (staging) => Effect.gen(function* () { validatedDirectory = staging.versionDir; @@ -142,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.notEqual(validatedDirectory, finalPaths.versionDir); assert.deepEqual(installed, finalPaths); assert.isTrue(yield* fs.exists(finalPaths.entryPath)); - assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); + assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), `${version}\n`); }), ); @@ -151,14 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, - runner: successfulRunner(fs, path), + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), }).pipe(Effect.flip); @@ -178,16 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, - runner: successfulRunner(fs, path), + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.void, }); @@ -201,18 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); - yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); + yield* fs.writeFileString(finalPaths.sentinelPath, `${version}\n`); let validations = 0; + const requests: string[] = []; yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, - runner: successfulRunner(fs, path), + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path), validate: (paths) => Effect.gen(function* () { validations += 1; @@ -224,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }).pipe(Effect.flip); assert.equal(validations, 1); + assert.deepEqual(requests, []); assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); }), ); @@ -239,9 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }); const install = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums), runner, validate: () => Effect.void, }).pipe(Effect.forkScoped); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 534ed917218f..680d80e46cd1 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -1,43 +1,70 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + CLI_RELEASE_CHECKSUMS_FILE, + cliArchiveFileName, + cliArchivePlatformKey, + cliArchiveTarCommand, + cliReleaseDownloadBaseUrl, + parseChecksums, +} from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; /** - * A pinned runtime is an exact `t3@` npm-installed into - * /runtime/versions/. The boot service points its unit or - * launch agent here, and server self-update installs the target version here before - * switching over, never `npx t3`, whose cache is ephemeral and whose - * registry fetch at boot would make startup depend on the network. + * A pinned runtime is an exact t3 release archive unpacked into + * /runtime/versions/: the self-contained executable, the + * web client, and the native packages beside it. The boot service points its + * unit or launch agent at the executable, and server self-update installs the + * target version here before switching over. The runtime never depends on a + * Node or npm on the machine; the only npm involvement in T3 Code is the `t3` + * package for people who prefer `npx t3` or `npm install -g t3`, and even a + * CLI installed that way pins an archive when it sets up the service. */ - const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; // Boot-service setup and remote update can construct separate layers. Serialize // the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); export interface PinnedRuntimePaths { readonly versionDir: string; + /** The executable. Its existence is what marks a runtime as present. */ readonly entryPath: string; readonly sentinelPath: string; } +/** The exact command that runs a pinned runtime. */ +export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { + readonly command: string; + readonly args: ReadonlyArray; +} { + return { command: paths.entryPath, args: [] }; +} + +export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string { + return path.join(baseDir, PINNED_RUNTIME_DIR, "versions"); +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, version: string, + platform: NodeJS.Platform, ): PinnedRuntimePaths { - const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version); return { versionDir, - entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), sentinelPath: path.join(versionDir, ".install-complete"), }; } @@ -72,11 +99,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError` into the pinned runtime directory unless a complete - * install is already there, and returns its paths. The sentinel is written - * only after npm exits 0; checking the entry file alone is not enough. npm - * extracts files before running native builds (node-pty), so a killed - * install leaves a plausible-looking but broken tree behind. + * Installs the t3 release archive for `version` into the pinned runtime + * directory unless a complete install is already there, and returns its + * paths. The sentinel is written only after extraction and validation + * succeed; checking the entry file alone is not enough, since tar writes the + * executable before the last native package and a killed install leaves a + * plausible-looking but broken tree behind. */ interface PinnedRuntimeInstallInput { readonly baseDir: string; @@ -87,13 +115,121 @@ interface PinnedRuntimeInstallInput { readonly validate: ( paths: PinnedRuntimePaths, ) => Effect.Effect; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly httpClient: HttpClient.HttpClient; + readonly releaseBaseUrl?: string | undefined; } +const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( + httpClient: HttpClient.HttpClient, + url: string, + step: string, +) { + // The install lock is held for the whole transaction, so a stalled download + // must fail rather than block every other caller. + return yield* httpClient.execute(HttpClientRequest.get(url)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), + Effect.timeoutOrElse({ + duration: PINNED_RUNTIME_INSTALL_TIMEOUT, + orElse: () => Effect.fail(new PinnedRuntimeInstallError({ step: `${step} (timed out)` })), + }), + ); +}); + +/** + * Downloads the release archive for this platform, verifies it against the + * release's checksum file, and unpacks it so the executable sits directly in + * the staging directory. Only `tar` is required on the host; every supported + * OS ships one that reads gzip and zip. + */ +const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* ( + input: PinnedRuntimeInstallInput, + stagingDir: string, +) { + const { fs, path } = input; + const platformKey = cliArchivePlatformKey(input.platform, input.arch); + if (platformKey === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `selecting a t3 release archive for ${input.platform}-${input.arch}`, + }); + } + const httpClient = input.httpClient; + const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); + const fileName = cliArchiveFileName(input.version, platformKey); + + const checksums = parseChecksums( + new TextDecoder().decode( + yield* fetchReleaseAsset( + httpClient, + `${baseUrl}/${CLI_RELEASE_CHECKSUMS_FILE}`, + "downloading the t3 release checksums", + ), + ), + ); + const expected = checksums.get(fileName); + if (expected === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `finding ${fileName} in the t3 release checksums`, + }); + } + const archive = yield* fetchReleaseAsset( + httpClient, + `${baseUrl}/${fileName}`, + "downloading the t3 release archive", + ); + const digest = yield* Effect.tryPromise({ + try: () => crypto.subtle.digest("SHA-256", archive), + catch: (cause) => + new PinnedRuntimeInstallError({ step: "verifying the t3 release archive", cause }), + }); + if (Encoding.encodeHex(new Uint8Array(digest)) !== expected) { + return yield* new PinnedRuntimeInstallError({ + step: "verifying the t3 release archive checksum", + }); + } + + const archivePath = path.join(stagingDir, PINNED_RUNTIME_ARCHIVE_FILE); + yield* fs + .writeFile(archivePath, archive) + .pipe( + Effect.mapError( + (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }), + ), + ); + const extractStep = "extracting the t3 release archive"; + // The archive wraps everything in one directory named after its stem; + // strip it so the executable lands at /t3. + yield* input.runner + .run({ + command: cliArchiveTarCommand(input.platform, process.env), + args: ["-xf", archivePath, "-C", stagingDir, "--strip-components=1"], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: extractStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: extractStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ); + yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); +}); + const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( input: PinnedRuntimeInstallInput, ) { - const { fs, runner } = input; - const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + const { fs } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.versionDir), fs.exists(paths.entryPath), @@ -147,53 +283,12 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ); const stagingPaths: PinnedRuntimePaths = { versionDir: stagingDir, - entryPath: input.path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), sentinelPath: input.path.join(stagingDir, ".install-complete"), }; return yield* Effect.gen(function* () { - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - const installArgs = [ - "install", - "--prefix", - stagingDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ]; - yield* runner - .run({ - command: "npm", - args: installArgs, - // Native dependencies may compile from source on slower machines. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.catchTags({ - ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? // pnpm-managed Node installations do not include npm. Keep npm - // installation semantics for the pinned runtime and native builds. - runner.run({ - command: "pnpm", - args: ["--package=npm@11", "dlx", "npm", ...installArgs], - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - : Effect.fail(error), - }), - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ); + yield* installFromArchive(input, stagingDir); yield* input.validate(stagingPaths); yield* fs diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index e6d8010f19d7..8ca0baa64a32 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -1,13 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; -import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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 Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; @@ -25,6 +26,28 @@ interface HarnessOptions { readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; } +// The staged runtime is a release archive: the fake client serves SHA256SUMS +// and the tarball, and the fake runner stands in for tar before it answers +// the staged preflight. +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const releaseHttpClient = (order: string[]) => + HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/SHA256SUMS")) { + const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes)); + const hex = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return HttpClientResponse.fromWeb( + request, + new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`), + ); + } + order.push("download"); + return HttpClientResponse.fromWeb(request, new Response(archiveBytes)); + }), + ); + const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( options: HarnessOptions = {}, ) { @@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const runner = ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - if (input.command === "npm") { - order.push("install"); - const prefix = input.args[input.args.indexOf("--prefix") + 1]; - if (prefix === undefined) return yield* Effect.die("missing npm prefix"); - const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + if (input.command === "tar") { + order.push("extract"); + const stagingDir = input.args[input.args.indexOf("-C") + 1]; + if (stagingDir === undefined) return yield* Effect.die("missing tar target"); + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( run: () => Effect.die("unexpected desktop app update run"), }, ), - Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), + Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); return { selfUpdate, order }; @@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { method: "boot-service", updateId: "launcher-id", }); - expect(order).toEqual(["install", "preflight", "accept"]); + expect(order).toEqual(["download", "extract", "preflight", "accept"]); }), ); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 3cea30790a4c..a388f8a0b4d7 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,22 +6,28 @@ import { type ServerSelfUpdateResult, type ThreadId, } from "@t3tools/contracts"; -import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as HashSet from "effect/HashSet"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; 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 { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, PinnedRuntimeInstallError, PinnedRuntimePreflightBlockedError, } from "./pinnedRuntime.ts"; @@ -170,7 +176,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const runner = yield* ProcessRunner.ProcessRunner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const execPath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; + // Archive-distributed targets download from GitHub Releases. The client is + // optional so callers without one (tests, npm-only hosts) still construct. + const httpClient = yield* HttpClient.HttpClient; + const releaseBaseUrl = Option.getOrUndefined( + yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), + ); const inFlight = yield* Ref.make(false); const capability: ServerSelfUpdateCapability | null = @@ -216,12 +229,16 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { fs, path, runner, + httpClient, + platform, + arch, + releaseBaseUrl, validate: (runtime) => runner .run({ - command: execPath, + command: pinnedRuntimeCommand(runtime).command, args: [ - runtime.entryPath, + ...pinnedRuntimeCommand(runtime).args, "__service-preflight", "--database-path", serverConfig.dbPath, diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index bb61866a93ff..a008cbc2030b 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -3,12 +3,16 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; /** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; -export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; export const SERVICE_STATE_FILE = "service-state.json"; /** Written by the launcher just before an explicit stop kills its child, so the child can tell "the service is going away" from "the launcher is about to start my replacement" while a pending update is recorded. */ export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; +/** Written by `t3 update` when the unit was repointed at a new version but the + running service was deliberately left on the old one. The launcher removes + it when it starts (whoever restarted the service), so while it exists the + service is known to be behind its unit and status reports it that way. */ +export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; export interface PendingServiceUpdate { readonly id: string; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1486f6b40a2a..b0544ef30aeb 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -13,6 +13,7 @@ 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 type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; @@ -79,6 +80,7 @@ export class ServerConfig extends Context.Service< readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + readonly devAuthToken?: Redacted.Redacted | undefined; readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index a60cda76c648..32de0b69a33f 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -58,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as ServerSettings from "../serverSettings.ts"; +import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -892,11 +893,17 @@ export const make = Effect.gen(function* () { }; const probeContext = yield* Effect.context>>(); + const localTargetContext = + yield* Effect.context>>(); const service = yield* makeWithHosts( hosts, (host) => - SshDeviceHost.probe(host).pipe( - Effect.provide(probeContext), + Effect.gen(function* () { + if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) { + return yield* localHost.summary; + } + return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext)); + }).pipe( Effect.mapError( (error) => new DeviceOperationError({ @@ -911,8 +918,11 @@ export const make = Effect.gen(function* () { const hostContext = yield* Effect.context>>(); const configured = new Map(); - const reconcile = (next: ReadonlyArray) => + const reconcile = (configuredHosts: ReadonlyArray) => Effect.gen(function* () { + const next = yield* remoteSshDeviceHosts(configuredHosts).pipe( + Effect.provide(localTargetContext), + ); const removed = yield* service.withLifecycleLock( Effect.gen(function* () { const removed: Array<{ id: string; scope: Scope.Closeable }> = []; diff --git a/apps/server/src/device/localSshDeviceHost.test.ts b/apps/server/src/device/localSshDeviceHost.test.ts new file mode 100644 index 000000000000..d4d202950a46 --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { + isLocalSshDeviceHost, + LocalDeviceHostAddresses, + remoteSshDeviceHosts, +} from "./localSshDeviceHost.ts"; + +const host = (target: string, port?: number) => ({ + id: target, + label: target, + target, + ...(port ? { port } : {}), +}); +const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); + // Any attempt to actually connect fails this test. + expect(command.args).toContain("-G"); + const target = command.args.at(-1); + const configs: Record = { + "mac-mini": "hostname 100.65.180.100\nport 22\n", + remote: "hostname 192.0.2.1\nport 22\n", + loopback: "hostname 127.0.1.1\nport 22\n", + ipv6: "hostname ::1\nport 22\n", + forwarded: "hostname 127.0.0.1\nport 2222\n", + proxy: "hostname 127.0.0.1\nport 22\nproxyjump bastion\n", + command: "hostname 127.0.0.1\nport 22\nproxycommand nc remote 22\n", + unresolved: "hostname example.invalid\nport 22\n", + }; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(configs[target ?? ""] ?? "")), + stderr: Stream.empty, + all: Stream.empty, + 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), + }); + }), +); +const provide = ( + effect: Effect.Effect>>, +) => + effect.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(LocalDeviceHostAddresses, new Set(["100.65.180.100"])), + Effect.provide(NodeServices.layer), + ); + +it.effect("skips SSH aliases resolving to this machine, including loopback", () => + provide( + Effect.gen(function* () { + for (const target of ["mac-mini", "loopback", "ipv6"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(true); + } + }), + ), +); + +it.effect("keeps remote, forwarded, proxied, and unresolved destinations", () => + provide( + Effect.gen(function* () { + for (const target of ["remote", "forwarded", "proxy", "command", "unresolved"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(false); + } + }), + ), +); + +it.effect("removes only self targets from a fanned-out host list", () => + provide( + Effect.gen(function* () { + expect( + yield* remoteSshDeviceHosts([host("mac-mini"), host("remote"), host("forwarded")]), + ).toEqual([host("remote"), host("forwarded")]); + }), + ), +); diff --git a/apps/server/src/device/localSshDeviceHost.ts b/apps/server/src/device/localSshDeviceHost.ts new file mode 100644 index 000000000000..0d1f615e1775 --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.ts @@ -0,0 +1,71 @@ +import * as NodeDnsPromises from "node:dns/promises"; +import * as NodeNet from "node:net"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Context from "effect/Context"; +import { runSshCommand } from "@t3tools/ssh/command"; +import * as Effect from "effect/Effect"; + +export const LocalDeviceHostAddresses = Context.Reference>( + "LocalDeviceHostAddresses", + { + defaultValue: () => + new Set( + Object.values(NodeOS.networkInterfaces()).flatMap( + (entries) => entries?.map((entry) => entry.address) ?? [], + ), + ), + }, +); + +/** Resolve aliases on the owning environment without opening an SSH connection. */ +export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* ( + host: SshDeviceHostConfig, +) { + const result = yield* runSshCommand( + { alias: host.target, hostname: host.target, username: null, port: host.port ?? null }, + { + preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])], + timeoutMs: 5000, + }, + ).pipe(Effect.result); + if (result._tag === "Failure") return false; + const config = new Map( + result.success.stdout.split("\n").map((line) => { + const separator = line.indexOf(" "); + return [line.slice(0, separator), line.slice(separator + 1).trim()]; + }), + ); + // A local forwarded port or a proxy can lead to a different machine. + if ( + config.get("port") !== "22" || + ["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none") + ) + return false; + const hostname = config.get("hostname")?.replace(/^\[|\]$/g, ""); + if (!hostname) return false; + const addresses = NodeNet.isIP(hostname) + ? [hostname] + : yield* Effect.tryPromise(() => NodeDnsPromises.lookup(hostname, { all: true })).pipe( + Effect.map((entries) => entries.map((entry) => entry.address)), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => [] as string[]), + ); + const localAddresses = yield* LocalDeviceHostAddresses; + return ( + addresses.length > 0 && + addresses.every( + (address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."), + ) + ); +}); + +export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(function* ( + hosts: ReadonlyArray, +) { + return yield* Effect.filter( + hosts, + (host) => isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local)), + { concurrency: 4 }, + ); +}); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5e3e5b0420f2..9f3231beb490 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -69,6 +69,7 @@ export class GitWorkflowService extends Context.Service< ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, + options?: GitVcsDriver.CreateWorktreeOptions, ) => Effect.Effect; readonly fetchRemote: (input: { readonly cwd: string; @@ -338,9 +339,9 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), - createWorktree: (input) => + createWorktree: (input, options) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( - Effect.andThen(git.createWorktree(input)), + Effect.andThen(git.createWorktree(input, options)), ), fetchRemote: (input) => ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac..c4f57726f20f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -55,7 +55,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; -import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { + ProviderRuntimeIngestionLive, + splitBufferedAssistantText, +} from "./ProviderRuntimeIngestion.ts"; import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -297,7 +300,24 @@ describe("ProviderRuntimeIngestion", () => { }); }), ).pipe(Layer.provide(projectionSnapshotLayer)); + // Real clock plus an offset the test can advance, so delivery pacing in + // ingestion can be driven without sleeping. Sleeps stay real. + let clockOffsetMs = 0; + const realClock = Effect.runSync(Effect.service(Clock.Clock)); + const shiftedClock: Clock.Clock = { + currentTimeMillisUnsafe: () => realClock.currentTimeMillisUnsafe() + clockOffsetMs, + currentTimeMillis: Effect.sync(() => realClock.currentTimeMillisUnsafe() + clockOffsetMs), + currentTimeNanosUnsafe: () => + realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + currentTimeNanos: Effect.sync( + () => realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + ), + monotonicTimeNanosUnsafe: () => realClock.monotonicTimeNanosUnsafe(), + monotonicTimeNanos: realClock.monotonicTimeNanos, + sleep: (duration) => realClock.sleep(duration), + }; const layer = ProviderRuntimeIngestionLive.pipe( + Layer.provide(Layer.succeed(Clock.Clock, shiftedClock)), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the @@ -392,6 +412,9 @@ describe("ProviderRuntimeIngestion", () => { .pipe(Effect.map(Option.getOrThrow)), ), emit: provider.emit, + advanceClock: (ms: number) => { + clockOffsetMs += ms; + }, emitAndDrain, sqlCount: sqlCounter.count, setProviderSession: provider.setSession, @@ -442,11 +465,11 @@ describe("ProviderRuntimeIngestion", () => { }); it.each([ - { delivery: "buffered", enableLegacyTokenStreaming: false }, - { delivery: "streamed", enableLegacyTokenStreaming: true }, + { delivery: "buffered", responseStreamingMode: "paragraph" as const }, + { delivery: "streamed", responseStreamingMode: "token" as const }, ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => { const harness = await createHarness({ - serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming }, + serverSettings: { responseStreamingMode: settings.responseStreamingMode }, }); const threadId = asThreadId("thread-1"); const turnId = asTurnId("opencode-aborted-turn"); @@ -498,7 +521,7 @@ describe("ProviderRuntimeIngestion", () => { "finalizes old buffered text on late %s without stopping the newer turn", async (terminalType) => { const harness = await createHarness({ - serverSettings: { enableLegacyTokenStreaming: false }, + serverSettings: { responseStreamingMode: "paragraph" }, }); const threadId = asThreadId("thread-1"); const oldTurnId = asTurnId("old-buffered-turn"); @@ -582,7 +605,7 @@ describe("ProviderRuntimeIngestion", () => { { source: "an unspecified turn", turnId: undefined }, ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => { const harness = await createHarness({ - serverSettings: { enableLegacyTokenStreaming: true }, + serverSettings: { responseStreamingMode: "token" }, }); const threadId = asThreadId("thread-1"); const stoppedTurnId = asTurnId("opencode-stopped-turn"); @@ -2689,7 +2712,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("keeps streaming while an async question is pending", async () => { - const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); + const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); const base = { provider: ProviderDriverKind.make("codex"), createdAt: "2026-01-01T00:00:00.000Z", @@ -2931,7 +2954,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("starts a new streaming assistant message segment after approval", async () => { - const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); + const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); const startedAt = "2026-03-28T07:00:00.000Z"; const pausedAt = "2026-03-28T07:00:01.000Z"; const resumedAt = "2026-03-28T07:00:02.000Z"; @@ -3038,7 +3061,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); + const harness = await createHarness({ serverSettings: { responseStreamingMode: "token" } }); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( @@ -3129,6 +3152,201 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); + it("delivers finished paragraphs while the rest of the message stays buffered", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paragraph-flush"); + const itemId = asItemId("item-paragraph-flush"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paragraph-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + + // Each delta lands well outside the pacing window of the one before. + const emitDelta = (eventId: string, delta: string) => { + harness.advanceClock(1_000); + harness.emit({ + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + }; + + emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para"); + const afterFirst = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === `assistant:${itemId}`, + ), + ); + expect( + afterFirst.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`), + ).toMatchObject({ + text: "First paragraph.\n\n", + streaming: true, + }); + + // An open code block holds the whole block until its closing fence lands. + emitDelta("evt-paragraph-2", "graph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n"); + await harness.drain(); + expect( + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text, + ).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + + emitDelta("evt-paragraph-3", "```\n\nTail without newline"); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-paragraph-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + const finalThread = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && !message.streaming, + ), + ); + expect( + finalThread.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`) + ?.text, + ).toBe( + "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nTail without newline", + ); + }); + + it("holds every paragraph until completion in turn mode", async () => { + const harness = await createHarness({ serverSettings: { responseStreamingMode: "turn" } }); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-wait-mode"); + const itemId = asItemId("item-wait-mode"); + + await harness.emitAndDrain([ + { + type: "turn.started", + eventId: asEventId("evt-wait-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }, + ]); + harness.advanceClock(1_000); + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId("evt-wait-delta"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: "First paragraph.\n\nSecond paragraph.\n\n", + }, + }, + ]); + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + // Paragraph mode would have delivered both paragraphs by now. + expect(await messageText()).toBeUndefined(); + + await harness.emitAndDrain([ + { + type: "item.completed", + eventId: asEventId("evt-wait-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }, + ]); + expect(await messageText()).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + }); + + it("holds paragraphs that finish inside the pacing window and lands them together", async () => { + const harness = await createHarness(); + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paced"); + const itemId = asItemId("item-paced"); + // Every delta carries the same event time, like OpenCode does for one + // part. Pacing must follow the server clock, not the event stamp. + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paced-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + // Emit is fire-and-forget, so drain after each delta before moving the + // clock. Otherwise the worker reads a clock that has already advanced. + let clockMs = 0; + const emitDelta = async (eventId: string, delta: string, offsetMs: number) => { + harness.advanceClock(offsetMs - clockMs); + clockMs = offsetMs; + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }, + ]); + }; + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + + await emitDelta("evt-paced-1", "One.\n\n", 0); + await emitDelta("evt-paced-2", "Two.\n\n", 100); + await emitDelta("evt-paced-3", "Three.\n\n", 200); + // The first paragraph lands right away. The next two are inside the window. + expect(await messageText()).toBe("One.\n\n"); + + await emitDelta("evt-paced-4", "Four.\n\n", 500); + expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n"); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -4367,3 +4585,70 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime still processed"); }); }); + +describe("splitBufferedAssistantText", () => { + it("keeps a partial trailing line buffered", () => { + expect(splitBufferedAssistantText("one\n\ntwo")).toEqual({ ready: "one\n\n", rest: "two" }); + expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" }); + }); + + it("does not split inside an open fence and delivers the block at its closing fence", () => { + const open = "intro\n\n```\ncode\n\nmore\n"; + expect(splitBufferedAssistantText(open)).toEqual({ + ready: "intro\n\n", + rest: "```\ncode\n\nmore\n", + }); + expect(splitBufferedAssistantText(`${open}\`\`\`\nafter`)).toEqual({ + ready: `${open}\`\`\`\n`, + rest: "after", + }); + }); + + it("does not treat a fence with an info string as a closing fence", () => { + const text = "```\n```javascript\nstill code\n\nmore\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + }); + + it("treats a fence indented four or more spaces as code, not a closing fence", () => { + const text = "```\n ```\n\nstill code\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + expect(splitBufferedAssistantText("```\n ```\nafter")).toEqual({ + ready: "```\n ```\n", + rest: "after", + }); + }); + + it("keeps a fence nested under a list item open across its blank lines", () => { + const text = "- step\n\n ```ts\n a\n\n b\n ```\n\nafter\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "- step\n\n ```ts\n a\n\n b\n ```\n\n", + rest: "after\n", + }); + }); + + it("does not treat a no-break-space line as blank", () => { + expect(splitBufferedAssistantText("para\n\u00a0\ncont\n\nnext")).toEqual({ + ready: "para\n\u00a0\ncont\n\n", + rest: "next", + }); + }); + + it("treats CRLF blank lines as boundaries", () => { + expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ + ready: "one\r\n\r\n", + rest: "two", + }); + }); + + it("only closes a fence with the same marker of equal or greater length", () => { + const text = "````\n```\nstill code\n\n````\n\nout\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "````\n```\nstill code\n\n````\n\n", + rest: "out\n", + }); + expect(splitBufferedAssistantText("~~~\n```\n\nx\n")).toEqual({ + ready: "", + rest: "~~~\n```\n\nx\n", + }); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 964f60d3a306..d7ae589047bf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,6 +1,5 @@ import { ApprovalRequestId, - type AssistantDeliveryMode, CommandId, MessageId, type OrchestrationEvent, @@ -14,11 +13,14 @@ import { TurnId, type OrchestrationCheckpointSummary, type OrchestrationThreadActivity, + type ProjectId, type ProviderRuntimeEvent, + type ResponseStreamingMode, RuntimeRequestId, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -107,6 +109,11 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; +// Paragraphs that finish within this window after a delivery stay buffered +// and land together on the next one. Keeps fast models from repainting the +// message several times a second while still showing the first paragraph +// as soon as it is done. +const MIN_ASSISTANT_DELIVERY_INTERVAL_MS = 400; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; type TurnStartRequestedDomainEvent = Extract< @@ -180,6 +187,58 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } +// An opening fence may sit at any indentation, since fences inside list +// items are indented past the marker. A closing fence may be indented at most +// three spaces more than its opener. Deeper lines are content in the block. +const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; +// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as +// a no-break space, is paragraph content. +const BLANK_LINE_PATTERN = /^[ \t]*$/; + +/** + * Splits buffered assistant text at the last blank line or closing code fence + * that is not inside an open fenced code block. `ready` is safe to deliver now + * because the markdown before it will not change shape as more text arrives. + * `rest` stays buffered until the next boundary or completion. Only fully + * terminated lines count, so a trailing partial line never leaks. + */ +export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { + let openFence: { marker: string; indent: number } | null = null; + let boundary = -1; + let lineStart = 0; + for (;;) { + const newline = text.indexOf("\n", lineStart); + if (newline === -1) { + break; + } + const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, ""); + const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); + if (fenceMatch) { + const indent = fenceMatch[1]!.length; + const marker = fenceMatch[2]!; + if (openFence === null) { + openFence = { marker, indent }; + } else if ( + marker[0] === openFence.marker[0] && + marker.length >= openFence.marker.length && + indent <= openFence.indent + 3 && + line.length === indent + marker.length + ) { + // CommonMark: a closing fence carries no info string. + openFence = null; + boundary = newline + 1; + } + } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { + boundary = newline + 1; + } + lineStart = newline + 1; + } + if (boundary === -1) { + return { ready: "", rest: text }; + } + return { ready: text.slice(0, boundary), rest: text.slice(boundary) }; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -927,6 +986,12 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, lookup: () => Effect.succeed(""), }); + // Epoch millis of the last early delivery per message, for pacing. + const lastAssistantDeliveryAtByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(0), + }); const assistantSegmentStateByTurnKey = yield* Cache.make({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1103,7 +1168,19 @@ const make = Effect.gen(function* () { }); }); - const appendBufferedAssistantText = (messageId: MessageId, delta: string) => + const resolveResponseStreamingMode = (projectId: ProjectId) => + Effect.map( + serverSettingsService.getSettings, + (settings) => resolveProjectSettings(settings, projectId).settings.responseStreamingMode, + ); + + // `mode` is "turn" or "paragraph"; token mode never buffers. + const appendBufferedAssistantText = ( + messageId: MessageId, + delta: string, + mode: Exclude, + atMillis: number, + ) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => Effect.gen(function* () { @@ -1111,6 +1188,34 @@ const make = Effect.gen(function* () { onNone: () => delta, onSome: (text) => `${text}${delta}`, }); + + // Paragraph mode delivers finished paragraphs and closed code blocks + // early so the user sees progress without token-by-token repaints. + // Turn mode holds everything until the turn finishes or pauses. + const { ready, rest } = + mode === "paragraph" + ? splitBufferedAssistantText(nextText) + : { ready: "", rest: nextText }; + const lastDeliveredAt = Option.getOrUndefined( + yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), + ); + const paced = + lastDeliveredAt === undefined || + atMillis - lastDeliveredAt >= MIN_ASSISTANT_DELIVERY_INTERVAL_MS; + if ( + paced && + hasRenderableAssistantText(ready) && + rest.length <= MAX_BUFFERED_ASSISTANT_CHARS + ) { + if (rest.length > 0) { + yield* Cache.set(bufferedAssistantTextByMessageId, messageId, rest); + } else { + yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + } + yield* Cache.set(lastAssistantDeliveryAtByMessageId, messageId, atMillis); + return ready; + } + if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); return ""; @@ -1133,7 +1238,9 @@ const make = Effect.gen(function* () { ); const clearBufferedAssistantText = (messageId: MessageId) => - Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + Cache.invalidate(bufferedAssistantTextByMessageId, messageId).pipe( + Effect.andThen(Cache.invalidate(lastAssistantDeliveryAtByMessageId, messageId)), + ); const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( @@ -1667,15 +1774,16 @@ const make = Effect.gen(function* () { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => - resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming - ? "streaming" - : "buffered", - ); - if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); + if (streamingMode !== "token") { + // Pace on the server clock. OpenCode stamps every delta of a part + // with the part's start time, so the event time cannot measure gaps. + const spillChunk = yield* appendBufferedAssistantText( + assistantMessageId, + assistantDelta, + streamingMode, + yield* Clock.currentTimeMillis, + ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", @@ -1711,15 +1819,9 @@ const make = Effect.gen(function* () { turnId: pauseForUserTurnId, streamingOnly: true, }); - const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => - resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming - ? "streaming" - : "buffered", - ); + const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); const flushedMessageIds = - assistantDeliveryMode === "buffered" + streamingMode !== "token" ? yield* flushBufferedAssistantMessagesForTurn({ event, threadId: thread.id, diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 37db33556e23..836b1f8f2e30 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -107,6 +107,9 @@ export class AuthSessionRepository extends Context.Service< readonly createReplacingActive: ( input: CreateReplacingActiveAuthSessionInput, ) => Effect.Effect, AuthSessionRepositoryError>; + readonly createIfAbsent: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; readonly getById: ( input: GetAuthSessionByIdInput, ) => Effect.Effect, AuthSessionRepositoryError>; @@ -204,10 +207,11 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const createSessionRow = SqlSchema.void({ - Request: CreateAuthSessionInput, - execute: (input) => - sql` + const insertSessionRow = (ignoreExisting: boolean) => + SqlSchema.void({ + Request: CreateAuthSessionInput, + execute: (input) => + sql` INSERT INTO auth_sessions ( session_id, subject, @@ -238,8 +242,11 @@ export const make = Effect.gen(function* () { ${input.expiresAt}, NULL ) + ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``} `, - }); + }); + const createSessionRow = insertSessionRow(false); + const createSessionRowIfAbsent = insertSessionRow(true); const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, @@ -393,6 +400,17 @@ export const make = Effect.gen(function* () { ), ); + const createIfAbsent: AuthSessionRepository["Service"]["createIfAbsent"] = (input) => + createSessionRowIfAbsent(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.createIfAbsent:query", + "AuthSessionRepository.createIfAbsent:encodeRequest", + { sessionId: input.sessionId }, + ), + ), + ); + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( @@ -493,6 +511,7 @@ export const make = Effect.gen(function* () { return { create, createReplacingActive, + createIfAbsent, getById, listActive, revoke, diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts index bd3e5128b1f1..dfa35020df33 100644 --- a/apps/server/src/persistence/Errors.test.ts +++ b/apps/server/src/persistence/Errors.test.ts @@ -3,7 +3,6 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts"; @@ -55,24 +54,6 @@ it("reads the condition through a wrapping driver error", () => { assert.equal(error.detail, "SQLITE(5) database is locked"); }); -it.each([{ errno: 1555, code: "SQLITE_CONSTRAINT_PRIMARYKEY" }, { errno: 1 }])( - "names Bun SQLite condition $errno through the SQL error wrapper", - (condition) => { - const driver = Object.assign(new Error("bun-sql-private-sentinel"), { - name: "SQLiteError", - ...condition, - }); - const cause = new SqlError({ reason: classifySqliteError(driver) }); - const error = toPersistenceSqlError("AuthSessionRepository.create:query")(cause); - - assert.equal( - error.message, - `SQL error in AuthSessionRepository.create:query: SQLITE(${condition.errno})`, - ); - assert.equal(error.cause, cause); - }, -); - it.each([ new Error("unhelpful"), Object.assign(new Error("file not found"), { errno: -2, code: "ENOENT" }), diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 7d6cf1701951..2715bf65a7b2 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -75,7 +75,7 @@ const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); /** * Read a SQLite condition through SQL error wrappers. - * Use Node's fixed description or Bun's numeric code, never the driver message. + * Use node:sqlite's fixed description, never the driver message. */ function sqliteCondition(cause: unknown): string | undefined { let value = cause; @@ -88,15 +88,6 @@ function sqliteCondition(cause: unknown): string | undefined { ) { return `SQLITE(${value.errcode}) ${value.errstr}`; } - if ( - "name" in value && - value.name === "SQLiteError" && - "errno" in value && - typeof value.errno === "number" && - Number.isInteger(value.errno) - ) { - return `SQLITE(${value.errno})`; - } value = "cause" in value ? value.cause : undefined; } return undefined; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 41d8f5baf3fd..88342cbf1fad 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -3,33 +3,11 @@ 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 type { SqlError } from "effect/unstable/sql/SqlError"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; -type RuntimeSqliteLayerConfig = { - readonly filename: string; - readonly spanAttributes?: Record; -}; - -type Loader = { - layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; -}; -const defaultSqliteClientLoaders = { - bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), - node: () => import("@t3tools/shared/nodeSqliteClient"), -} satisfies Record Promise>; - -const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( - config: RuntimeSqliteLayerConfig, -) { - const runtime = process.versions.bun !== undefined ? "bun" : "node"; - const loader = defaultSqliteClientLoaders[runtime]; - const clientModule = yield* Effect.promise(loader); - return clientModule.layer(config); -}, Layer.unwrap); - const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -50,7 +28,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( return Layer.provideMerge( setup, - makeRuntimeSqliteLayer({ + NodeSqliteClient.layer({ filename: dbPath, spanAttributes: { "db.name": path.basename(dbPath), @@ -62,7 +40,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( export const SqlitePersistenceMemory = Layer.provideMerge( setup, - makeRuntimeSqliteLayer({ filename: ":memory:" }), + NodeSqliteClient.layer({ filename: ":memory:" }), ); export const layerConfig = Layer.unwrap( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index fc582ef35516..c0d15f105af7 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import { type OrchestrationProject, ProjectId, type TerminalEvent } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -54,11 +55,11 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => searchThreads: () => Effect.succeed({ matches: [] }), }); -const makeTerminalManagerLayer = ( - overrides: Pick, -) => +type TerminalOverrides = Pick & + Partial>; + +const makeTerminalManagerLayer = (overrides: TerminalOverrides) => Layer.succeed(TerminalManager.TerminalManager, { - ...overrides, attachStream: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, @@ -66,11 +67,12 @@ const makeTerminalManagerLayer = ( close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), + ...overrides, }); const testLayer = ( project: OrchestrationProject, - terminal: Pick, + terminal: TerminalOverrides, settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( @@ -198,6 +200,7 @@ describe("ProjectSetupScriptRunner", () => { status: "started", scriptId: "setup", scriptName: "Setup", + scriptCommand: "bun install", terminalId: "setup-setup", cwd: "/repo/worktrees/a", }); @@ -220,6 +223,209 @@ describe("ProjectSetupScriptRunner", () => { }, ); + it.effect( + "wraps the command with a completion sentinel and resolves the exit code from terminal output", + () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const writes: string[] = []; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void writes.push(input.data)), + ); + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect) => { + listener = next; + return Effect.succeed(() => { + listener = null; + }); + }); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + const emit = (data: string) => + Effect.suspend(() => + listener + ? listener({ threadId: "thread-1", terminalId: "setup-setup", type: "output", data }) + : Effect.void, + ); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const seen: string[] = []; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: { + onOutputLine: (line) => Effect.sync(() => void seen.push(line)), + }, + }); + expect(result.status).toBe("started"); + if (result.status !== "started") return; + expect(result.completion).toBeDefined(); + + // The subscription is attached before the command is written. + expect(subscribe).toHaveBeenCalledTimes(1); + expect(writes).toHaveLength(1); + // The block closes on its own line so a trailing comment in the + // command cannot swallow the sentinel, and the sentinel carries a + // per-run token so script output cannot spoof it. + const written = writes[0] ?? ""; + const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; + expect(sentinel).toBeDefined(); + expect(written).toBe(`( bun install\r); printf '\\n${sentinel}%s\\n' "$?"\r`); + + // Output arrives in chunks; partial lines are buffered until a newline, + // control sequences are stripped, and the echoed wrapper is hidden. + yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`); + yield* emit("\u001b[32mResolving"); + yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n"); + // A spoofed sentinel from the script itself must not settle completion. + yield* emit("__T3_SETUP_DONE__:0\r\n"); + yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`); + yield* emit(`${sentinel}3\r\n`); + + const completion = yield* result.completion!; + expect(completion.exitCode).toBe(3); + expect(seen).toEqual([ + "Resolving deps", + "Done in 2s", + "__T3_SETUP_DONE__:0", + `__T3_SETUP_DONE___${"0".repeat(32)}:0`, + ]); + // The subscription is torn down once the sentinel arrives. + expect(listener).toBeNull(); + }).pipe( + Effect.provide(testLayer(project, { open, write, subscribe })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), + ); + }, + ); + + it.effect("unsubscribes from terminal output when the command cannot be written", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => + Effect.fail( + new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", cause: {} }), + ), + ); + const unsubscribe = vi.fn(); + const subscribe = vi.fn(() => Effect.succeed(unsubscribe)); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner + .runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(testLayer(project, { open, write, subscribe }))); + }); + + it.effect.each([ + { + shell: "/usr/bin/fish", + expected: + /^begin\rbun install\rend; printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' \$status\r$/, + }, + { + shell: "/bin/bash", + expected: /^\( bun install\r\); printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' "\$\?"\r$/, + }, + ])("wraps the command for the $shell syntax", ({ shell, expected }) => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const writes: string[] = []; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void writes.push(input.data)), + ); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }); + expect(writes).toHaveLength(1); + expect(writes[0]).toMatch(expected); + }).pipe( + Effect.provide(testLayer(project, { open, write })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: shell }), + ); + }); + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { const rootCause = new Error("stat failed"); const terminalError = new TerminalManager.TerminalCwdStatError({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 3bbb0daa7994..c80dd535f514 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,10 +1,15 @@ import { ProjectId } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { projectScriptRuntimeEnv, resolveProjectScripts, setupProjectScript, } from "@t3tools/shared/projectScripts"; +import * as NodeCrypto from "node:crypto"; + +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -22,8 +27,24 @@ export interface ProjectSetupScriptRunnerResultStarted { readonly status: "started"; readonly scriptId: string; readonly scriptName: string; + readonly scriptCommand: string; readonly terminalId: string; readonly cwd: string; + /** + * Resolves when the script's shell prints the completion sentinel. The + * exit code is null when the terminal exited or was closed before the + * sentinel arrived. Only present when `observeCompletion` was requested. + */ + readonly completion?: Effect.Effect; +} + +export interface ProjectSetupScriptCompletion { + readonly exitCode: number | null; + readonly durationMs: number; +} + +export interface ProjectSetupScriptOutputLine { + readonly line: string; } export type ProjectSetupScriptRunnerResult = @@ -36,6 +57,14 @@ export interface ProjectSetupScriptRunnerInput { readonly projectCwd?: string; readonly worktreePath: string; readonly preferredTerminalId?: string; + /** + * Wrap the command so the shell reports its exit code back through the + * terminal stream, and forward cleaned output lines while it runs. The + * bootstrap flow uses this to drive the worktree setup card. + */ + readonly observeCompletion?: { + readonly onOutputLine?: (line: string) => Effect.Effect; + }; } export class ProjectSetupScriptOperationError extends Schema.TaggedError()( @@ -83,11 +112,170 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} +/** @public Service construction is part of the canonical Effect module API. */ +/** + * Marker the wrapped setup command echoes so the exit code can be read from + * the PTY stream. Each run gets its own random token so script output cannot + * spoof completion, and the sentinel pattern is built per run from it. + */ +const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__"; +const OUTPUT_LINE_MAX_LENGTH = 400; +/** A partial line longer than this is a byte stream, not a line. Keep only the tail. */ +const PARTIAL_LINE_MAX_LENGTH = 4_096; + +function completionSentinel(token: string): string { + return `${COMPLETION_SENTINEL_PREFIX}_${token}:`; +} + +function completionSentinelPattern(token: string): RegExp { + return new RegExp(`${COMPLETION_SENTINEL_PREFIX}_${token}:(-?\\d+)`); +} + +/** Removes ANSI escape sequences and cursor controls so lines can be shown as plain text. */ +function stripTerminalControl(text: string): string { + return ( + text + .replace( + // eslint-disable-next-line no-control-regex + /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, + "", + ) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + ); +} + +type CompletionShell = "posix" | "fish" | "powershell"; + +/** + * Predicts the shell TerminalManager will spawn for the setup terminal. The + * manager takes `$SHELL` on POSIX and PowerShell on Windows, falling back to + * other shells only when that one fails to spawn. + */ +function resolveCompletionShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): CompletionShell { + if (platform === "win32") return "powershell"; + const shell = env.SHELL ?? ""; + const name = shell.split("/").at(-1) ?? shell; + if (name === "fish") return "fish"; + if (name === "pwsh" || name === "powershell") return "powershell"; + return "posix"; +} + +/** + * Builds the shell input for the setup script. The command runs inside a + * block and the block closes on its own line, so a trailing `# comment` or a + * heredoc terminator in the command cannot swallow the sentinel. The shell + * reads the whole block before running any of it, so a script that reads + * stdin cannot consume the sentinel line either. Lines are separated by `\r` + * because that is the Enter key for every shell's line editor. + */ +function wrapCommandForCompletion( + command: string, + shell: CompletionShell, + sentinel: string, +): string { + const body = command.replace(/\r?\n/g, "\r"); + switch (shell) { + case "powershell": + return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`; + case "fish": + return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`; + case "posix": + return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; + } +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; + const completionShell = resolveCompletionShell( + yield* HostProcessPlatform, + yield* HostProcessEnvironment, + ); + + /** + * Watches the setup terminal for the completion sentinel. Terminal output is + * a byte stream, so partial lines are buffered until a newline. The + * subscription is torn down once the sentinel, an exit, or a close arrives. + */ + const observeTerminalCompletion = (input: { + readonly threadId: string; + readonly terminalId: string; + /** Per-run sentinel, so only this run's wrapper can settle completion. */ + readonly sentinel: string; + readonly sentinelPattern: RegExp; + /** The shell echoes typed input; lines ending with these are the wrapper, not output. */ + readonly echoedWrapperLines: ReadonlyArray; + readonly onOutputLine: ((line: string) => Effect.Effect) | undefined; + }) => + Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const done = yield* Deferred.make(); + let lineBuffer = ""; + let settled = false; + + const settle = (exitCode: number | null) => + Effect.suspend(() => { + if (settled) return Effect.void; + settled = true; + return Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMs) => + Deferred.succeed(done, { exitCode, durationMs: nowMs - startedAtMs }), + ), + Effect.asVoid, + ); + }); + + const handleLine = (rawLine: string) => + Effect.suspend(() => { + const sentinel = input.sentinelPattern.exec(rawLine); + if (sentinel) { + const parsed = Number(sentinel[1]); + return settle(Number.isFinite(parsed) ? parsed : null); + } + const cleaned = stripTerminalControl(rawLine).trimEnd(); + if ( + cleaned.length === 0 || + cleaned.includes(input.sentinel) || + input.echoedWrapperLines.some((echoed) => cleaned.endsWith(echoed)) || + input.onOutputLine === undefined + ) { + return Effect.void; + } + return input.onOutputLine(cleaned.slice(0, OUTPUT_LINE_MAX_LENGTH)); + }); + + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + if (event.type === "output") { + lineBuffer += event.data; + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() ?? ""; + // A script that never prints a newline must not grow this forever. + // The sentinel is always on its own line, so keeping the tail is safe. + if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { + lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); + } + return Effect.forEach(lines, handleLine, { discard: true }); + } + if (event.type === "exited" || event.type === "closed") { + return settle(null); + } + return Effect.void; + }); + + const completion = Deferred.await(done).pipe( + Effect.ensuring(Effect.sync(() => unsubscribe())), + ); + return { completion, unsubscribe }; + }); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", @@ -154,6 +342,16 @@ export const make = Effect.gen(function* () { project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }); + const observe = input.observeCompletion; + const completionToken = observe ? NodeCrypto.randomUUID().replaceAll("-", "") : null; + const commandLine = + observe && completionToken + ? wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ) + : script.command; yield* terminalManager .open({ @@ -173,11 +371,24 @@ export const make = Effect.gen(function* () { }), ), ); + // Subscribe before writing so the sentinel cannot race past the listener. + const observed = + observe && completionToken + ? yield* observeTerminalCompletion({ + threadId: input.threadId, + terminalId, + sentinel: completionSentinel(completionToken), + sentinelPattern: completionSentinelPattern(completionToken), + echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + onOutputLine: observe.onOutputLine, + }) + : undefined; + yield* terminalManager .write({ threadId: input.threadId, terminalId, - data: `${script.command}\r`, + data: `${commandLine}\r`, }) .pipe( Effect.mapError( @@ -188,14 +399,18 @@ export const make = Effect.gen(function* () { cause, }), ), + // Nothing will ever settle the completion if the command never ran. + Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), ); return { status: "started", scriptId: script.id, scriptName: script.name, + scriptCommand: script.command, terminalId, cwd, + ...(observed ? { completion: observed.completion } : {}), } as const; }); diff --git a/apps/server/src/project/WorktreeSetupTracker.test.ts b/apps/server/src/project/WorktreeSetupTracker.test.ts new file mode 100644 index 000000000000..16e7b6aa64c2 --- /dev/null +++ b/apps/server/src/project/WorktreeSetupTracker.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts"; + +const threadId = ThreadId.make("thread-1"); + +describe("WorktreeSetupTracker", () => { + it.effect("records stage transitions, checkout progress, and the final phase", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: "feature", + baseRef: "main", + stages: ["checkout", "fetch", "agent"], + fiber: null, + }); + + const initial = yield* tracker.get(threadId); + // Stages are reordered into the canonical setup order. + expect(initial?.stages.map((stage) => stage.id)).toEqual(["fetch", "checkout", "agent"]); + expect(initial?.phase).toBe("running"); + + yield* tracker.stageStatus(threadId, "fetch", "running"); + yield* tracker.stageStatus(threadId, "fetch", "done", "origin/main at abc1234"); + yield* tracker.stageStatus(threadId, "checkout", "running"); + yield* tracker.stage(threadId, "checkout", { percent: 42, detail: "42 / 100 files" }); + yield* tracker.finish(threadId, "failed", "boom"); + + const final = yield* tracker.get(threadId); + expect(final?.phase).toBe("failed"); + expect(final?.error).toBe("boom"); + const [fetch, checkout, agent] = final?.stages ?? []; + expect(fetch).toMatchObject({ status: "done", detail: "origin/main at abc1234" }); + expect(fetch?.startedAt).not.toBeNull(); + expect(fetch?.endedAt).not.toBeNull(); + // A stage still running when the setup fails is marked failed. + expect(checkout).toMatchObject({ status: "failed", percent: 42 }); + expect(agent?.status).toBe("pending"); + expect(final?.sequence).toBeGreaterThan(initial?.sequence ?? 0); + }), + ); + + it.effect("stream emits the current snapshot first and then only newer ones", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.sequence === 2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.stageStatus(threadId, "agent", "running"); + yield* tracker.appendTail(threadId, "agent", "line 1"); + + const snapshots = yield* Fiber.join(collected); + const sequences = snapshots.map((snapshot) => snapshot?.sequence ?? -1); + expect(sequences.at(-1)).toBe(2); + // Delivery is latest-value per subscriber, so intermediates may be + // skipped but never delivered out of order. + expect(sequences).toEqual([...sequences].toSorted((a, b) => a - b)); + expect(snapshots.at(-1)?.stages[0]?.tail).toEqual(["line 1"]); + }), + ); + + it.effect("stream never steps back behind the snapshot it started from", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.stageStatus(threadId, "agent", "running"); + yield* tracker.stageStatus(threadId, "agent", "done"); + + // A late subscriber starts at sequence 2 and must never see 0 or 1. + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.phase === "done"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.finish(threadId, "done"); + + const snapshots = yield* Fiber.join(collected); + expect(snapshots.length).toBeGreaterThan(0); + expect(snapshots.every((snapshot) => (snapshot?.sequence ?? -1) >= 2)).toBe(true); + expect(snapshots.at(-1)?.phase).toBe("done"); + }), + ); + + it.effect("a new setup on the same thread keeps sequences increasing", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: "first", + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.finish(threadId, "failed", "boom"); + const failedSequence = (yield* tracker.get(threadId))?.sequence ?? -1; + + // A stream opened on the failed setup must still receive the next one. + const collected = yield* tracker.stream(threadId).pipe( + Stream.takeUntil((snapshot) => snapshot?.branch === "second"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* tracker.begin({ + threadId, + branch: "second", + baseRef: null, + stages: ["agent"], + fiber: null, + }); + + const snapshots = yield* Fiber.join(collected); + const last = snapshots.at(-1); + expect(last?.phase).toBe("running"); + expect(last?.sequence).toBeGreaterThan(failedSequence); + }), + ); + + it.effect("finished setups are dropped after the retention window", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["agent"], + fiber: null, + }); + yield* tracker.finish(threadId, "done"); + expect((yield* tracker.get(threadId))?.phase).toBe("done"); + + yield* TestClock.adjust(Duration.seconds(31)); + expect(yield* tracker.get(threadId)).toBeNull(); + }), + ); + + it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + const started = yield* Deferred.make(); + const fiber = yield* Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.forkChild, + ); + yield* Deferred.await(started); + yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); + + expect(yield* tracker.cancel(threadId)).toBe(true); + // cancel returns only after the bootstrap fiber has unwound. + const exit = yield* Fiber.await(fiber); + expect(Exit.hasInterrupts(exit)).toBe(true); + + yield* tracker.finish(threadId, "cancelled"); + expect(yield* tracker.cancel(threadId)).toBe(false); + expect(yield* tracker.cancel(ThreadId.make("unknown"))).toBe(false); + }), + ); + + it.effect("markUncancellable makes a later cancel a no-op while the setup keeps running", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + const fiber = yield* Effect.forkChild(Effect.never); + yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber }); + + yield* tracker.markUncancellable(threadId); + expect(yield* tracker.cancel(threadId)).toBe(false); + expect((yield* tracker.get(threadId))?.phase).toBe("running"); + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("clamps free text to the contract limits before publishing", () => + Effect.gen(function* () { + const tracker = yield* WorktreeSetupTracker.make; + yield* tracker.begin({ + threadId, + branch: null, + baseRef: null, + stages: ["checkout", "setup-script"], + fiber: null, + }); + const long = "x".repeat(2_000); + + yield* tracker.stageStatus(threadId, "checkout", "done", long); + yield* tracker.stage(threadId, "setup-script", { detail: long }); + yield* tracker.appendTail(threadId, "setup-script", long); + yield* tracker.finish(threadId, "failed", long); + + const snapshot = yield* tracker.get(threadId); + expect(snapshot?.stages[0]?.detail?.length).toBe(200); + expect(snapshot?.stages[1]?.detail?.length).toBe(200); + expect(snapshot?.stages[1]?.tail[0]?.length).toBe(400); + expect(snapshot?.error?.length).toBe(1000); + // The wire schema must accept what the tracker publishes. + expect(Schema.is(WorktreeSetupSnapshot)(snapshot)).toBe(true); + }), + ); +}); diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts new file mode 100644 index 000000000000..41c84f2d649d --- /dev/null +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -0,0 +1,364 @@ +import type { + ThreadId, + WorktreeSetupSnapshot, + WorktreeSetupStage, + WorktreeSetupStageId, + WorktreeSetupStageStatus, +} from "@t3tools/contracts"; +import { + WORKTREE_SETUP_DETAIL_MAX_LENGTH, + WORKTREE_SETUP_ERROR_MAX_LENGTH, + WORKTREE_SETUP_STAGE_ORDER, + WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +/** + * Tracks the live stages of a bootstrap worktree setup per thread so clients + * can render a progress card while the first turn is still being prepared. + * + * State is memory only. It exists from the first `begin` until the turn starts + * or the setup fails, plus a short grace window so a client that subscribes + * late still sees the final state. Nothing here is persisted or event-sourced: + * the durable record of a setup is the thread's worktree path and the setup + * script activities, both of which already exist. + */ +export class WorktreeSetupTracker extends Context.Service< + WorktreeSetupTracker, + { + /** Creates a fresh running snapshot for the thread, replacing any prior one. */ + readonly begin: (input: { + readonly threadId: ThreadId; + readonly branch: string | null; + readonly baseRef: string | null; + readonly stages: ReadonlyArray; + /** Interrupting this fiber cancels the bootstrap. */ + readonly fiber: Fiber.Fiber | null; + }) => Effect.Effect; + readonly update: ( + threadId: ThreadId, + mutate: (snapshot: WorktreeSetupSnapshot) => WorktreeSetupSnapshot, + ) => Effect.Effect; + readonly stage: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + patch: Partial>, + ) => Effect.Effect; + readonly stageStatus: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + status: WorktreeSetupStageStatus, + detail?: string | null, + ) => Effect.Effect; + readonly appendTail: ( + threadId: ThreadId, + stageId: WorktreeSetupStageId, + line: string, + ) => Effect.Effect; + readonly finish: ( + threadId: ThreadId, + phase: "done" | "failed" | "cancelled", + error?: string | null, + ) => Effect.Effect; + /** + * Drops the cancel handle. Called right before the turn is dispatched so a + * late cancel cannot roll back a thread whose agent has already started. + */ + readonly markUncancellable: (threadId: ThreadId) => Effect.Effect; + /** + * Interrupts the running bootstrap and waits for it to unwind, so the + * caller's dispatch has already failed and rolled back when this returns. + * Returns false when nothing is running or the setup is past cancellation. + */ + readonly cancel: (threadId: ThreadId) => Effect.Effect; + readonly get: (threadId: ThreadId) => Effect.Effect; + /** Emits the current snapshot (or null) first, then every change until unsubscribed. */ + readonly stream: (threadId: ThreadId) => Stream.Stream; + } +>()("t3/project/WorktreeSetupTracker") {} + +const TAIL_LINE_LIMIT = 4; + +/** Keeps free text inside the contract limit, ending in an ellipsis when cut. */ +function clampText(text: string, maxLength: number): string { + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}\u2026`; +} + +const clampDetail = (detail: string | null): string | null => + detail === null ? null : clampText(detail, WORKTREE_SETUP_DETAIL_MAX_LENGTH); +/** Finished snapshots stay visible this long so a late subscriber sees the outcome. */ +const FINISHED_RETENTION = "30 seconds"; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +interface TrackedSetup { + readonly snapshot: WorktreeSetupSnapshot; + readonly fiber: Fiber.Fiber | null; +} + +function emptyStage(id: WorktreeSetupStageId): WorktreeSetupStage { + return { + id, + status: "pending", + startedAt: null, + endedAt: null, + percent: null, + detail: null, + tail: [], + }; +} + +export const make = Effect.gen(function* () { + const setups = yield* Ref.make(new Map()); + const changes = yield* PubSub.unbounded<{ + readonly threadId: ThreadId; + readonly snapshot: WorktreeSetupSnapshot | null; + }>(); + const retentionFibers = new Map>(); + // Sequences keep increasing across setups of the same thread so a stream + // opened during a previous setup still accepts the next one's first snapshot. + const lastSequenceByThread = new Map(); + + const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) => + PubSub.publish(changes, { threadId, snapshot }).pipe(Effect.asVoid); + + const modify = ( + threadId: ThreadId, + mutate: (tracked: TrackedSetup) => TrackedSetup, + ): Effect.Effect => + Ref.modify(setups, (current) => { + const existing = current.get(threadId); + if (!existing) return [null, current] as const; + const nextTracked = mutate(existing); + const nextSnapshot = { + ...nextTracked.snapshot, + sequence: existing.snapshot.sequence + 1, + }; + lastSequenceByThread.set(threadId, nextSnapshot.sequence); + const next = new Map(current); + next.set(threadId, { ...nextTracked, snapshot: nextSnapshot }); + return [nextSnapshot, next] as const; + }).pipe(Effect.tap((snapshot) => (snapshot ? publish(threadId, snapshot) : Effect.void))); + + const clearRetention = (threadId: ThreadId) => { + const fiber = retentionFibers.get(threadId); + retentionFibers.delete(threadId); + return fiber ? Fiber.interrupt(fiber).pipe(Effect.ignore) : Effect.void; + }; + + const remove = (threadId: ThreadId) => + Ref.update(setups, (current) => { + if (!current.has(threadId)) return current; + const next = new Map(current); + next.delete(threadId); + return next; + }).pipe( + Effect.andThen(publish(threadId, null)), + // A subscriber that outlives retention sees `null` here and accepts any + // sequence after it, so the counter can start over for this thread. + Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), + ); + + const begin: WorktreeSetupTracker["Service"]["begin"] = (input) => + Effect.gen(function* () { + yield* clearRetention(input.threadId); + const startedAt = yield* nowIso; + const ordered = WORKTREE_SETUP_STAGE_ORDER.filter((id) => input.stages.includes(id)); + const snapshot: WorktreeSetupSnapshot = { + threadId: input.threadId, + phase: "running", + startedAt, + endedAt: null, + branch: input.branch, + baseRef: input.baseRef, + worktreePath: null, + setupScript: null, + stages: ordered.map(emptyStage), + error: null, + sequence: (lastSequenceByThread.get(input.threadId) ?? -1) + 1, + }; + lastSequenceByThread.set(input.threadId, snapshot.sequence); + yield* Ref.update(setups, (current) => { + const next = new Map(current); + next.set(input.threadId, { snapshot, fiber: input.fiber }); + return next; + }); + yield* publish(input.threadId, snapshot); + }); + + const update: WorktreeSetupTracker["Service"]["update"] = (threadId, mutate) => + modify(threadId, (tracked) => ({ ...tracked, snapshot: mutate(tracked.snapshot) })).pipe( + Effect.asVoid, + ); + + const stage: WorktreeSetupTracker["Service"]["stage"] = (threadId, stageId, patch) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => + entry.id === stageId + ? { + ...entry, + ...patch, + ...(patch.detail === undefined ? {} : { detail: clampDetail(patch.detail ?? null) }), + } + : entry, + ), + })); + + const stageStatus: WorktreeSetupTracker["Service"]["stageStatus"] = ( + threadId, + stageId, + status, + detail, + ) => + nowIso.pipe( + Effect.flatMap((at) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => { + if (entry.id !== stageId) return entry; + const startedAt = entry.startedAt ?? (status === "pending" ? null : at); + const endedAt = + status === "running" || status === "pending" ? null : (entry.endedAt ?? at); + return { + ...entry, + status, + startedAt, + endedAt, + ...(detail === undefined ? {} : { detail: clampDetail(detail ?? null) }), + }; + }), + })), + ), + ); + + const appendTail: WorktreeSetupTracker["Service"]["appendTail"] = (threadId, stageId, line) => + update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((entry) => + entry.id === stageId + ? { + ...entry, + tail: [...entry.tail, clampText(line, WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH)].slice( + -TAIL_LINE_LIMIT, + ), + } + : entry, + ), + })); + + const finish: WorktreeSetupTracker["Service"]["finish"] = (threadId, phase, error) => + Effect.gen(function* () { + const endedAt = yield* nowIso; + const snapshot = yield* modify(threadId, (tracked) => ({ + fiber: null, + snapshot: { + ...tracked.snapshot, + phase, + endedAt, + error: + error === undefined || error === null + ? null + : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH), + stages: tracked.snapshot.stages.map((entry) => + entry.status === "running" + ? { + ...entry, + status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed", + endedAt, + } + : entry, + ), + }, + })); + if (!snapshot) return; + yield* clearRetention(threadId); + const fiber = yield* remove(threadId).pipe( + Effect.delay(FINISHED_RETENTION), + Effect.ensuring( + Effect.sync(() => { + // Only drop our own entry: a newer setup may have replaced it. + if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId); + }), + ), + Effect.forkDetach, + ); + retentionFibers.set(threadId, fiber); + }); + + const markUncancellable: WorktreeSetupTracker["Service"]["markUncancellable"] = (threadId) => + Ref.update(setups, (current) => { + const existing = current.get(threadId); + if (!existing || existing.fiber === null) return current; + const next = new Map(current); + next.set(threadId, { ...existing, fiber: null }); + return next; + }); + + const cancel: WorktreeSetupTracker["Service"]["cancel"] = (threadId) => + Effect.gen(function* () { + const current = yield* Ref.get(setups); + const tracked = current.get(threadId); + if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) { + return false; + } + yield* Fiber.interrupt(tracked.fiber); + return true; + }); + + const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => + Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); + + /** + * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only + * ever holds the newest snapshot, so a chatty setup script cannot grow the + * server heap. Snapshots are whole states, so skipping intermediates is safe. + */ + const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) => + Stream.callback( + (mailbox) => + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const initial = yield* get(threadId); + // Changes published between subscribing and reading `initial` are + // already folded into it. Drop them so the client never steps back. + let lastSequence = initial?.sequence ?? -1; + Queue.offerUnsafe(mailbox, initial); + yield* Stream.fromSubscription(subscription).pipe( + Stream.runForEach((change) => + Effect.sync(() => { + if (change.threadId !== threadId) return; + if (change.snapshot !== null && change.snapshot.sequence <= lastSequence) return; + lastSequence = change.snapshot?.sequence ?? -1; + Queue.offerUnsafe(mailbox, change.snapshot); + }), + ), + Effect.forkScoped, + ); + }), + { bufferSize: 1, strategy: "sliding" }, + ); + + return WorktreeSetupTracker.of({ + begin, + update, + stage, + stageStatus, + appendTail, + finish, + markUncancellable, + cancel, + get, + stream, + }); +}); + +export const layer = Layer.effect(WorktreeSetupTracker, make); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index fc52fe38dc68..3afde2b39a7a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off /** * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. * @@ -6,6 +7,7 @@ * * @module ClaudeAdapterLive */ + import { type CanUseTool, query, @@ -67,6 +69,7 @@ import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, formatClaudeResumeCompactionQuestion, } from "@t3tools/shared/claudeCompaction"; +import { HostProcessIsExecutable } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -5128,16 +5131,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( detail: "Claude session id is unavailable.", }); } - const historyWorkerPath = yield* path - .fromFileUrl( - new URL( - import.meta.url.endsWith(".ts") - ? "../../claudeHistoryWorker.ts" - : "./claudeHistoryWorker.mjs", - import.meta.url, - ), - ) - .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))); + // The single-executable has no sibling script and no Node to run one + // with, so it hosts the worker as a hidden subcommand of itself. + const historyWorkerArguments = (yield* HostProcessIsExecutable) + ? ["__claude-history"] + : [ + yield* path + .fromFileUrl( + new URL( + import.meta.url.endsWith(".ts") + ? "../../claude-history-worker.ts" + : "./claude-history-worker.mjs", + import.meta.url, + ), + ) + .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))), + ]; const runScopedHistoryCommand = async ( method: "getSessionMessages" | "forkSession", args: object, @@ -5150,7 +5159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( process.execPath, ChildProcess.make( process.execPath, - [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)], + [...historyWorkerArguments, method, historySessionId, encodeHistoryArgs(args)], { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, ), ).pipe( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index af934c59d480..3eb40a1b9906 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -148,6 +148,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; @@ -924,9 +925,12 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(TerminalManager.TerminalManager)({ - ...options?.layers?.terminalManager, - }), + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({ + ...options?.layers?.terminalManager, + }), + WorktreeSetupTracker.layer, + ), ), Layer.provide( Layer.mergeAll( @@ -10558,6 +10562,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: "started" as const, scriptId: "setup", scriptName: "Setup", + scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", }), @@ -10673,12 +10678,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "resolve-remote-commit", "create-worktree", ]); - assert.deepEqual(runForThread.mock.calls[0]?.[0], { - threadId: ThreadId.make("thread-bootstrap"), - projectId: defaultProjectId, - projectCwd: "/tmp/project", - worktreePath: "/tmp/bootstrap-worktree", - }); + const runForThreadInput = runForThread.mock.calls[0]?.[0]; + assert.deepEqual( + runForThreadInput && { + threadId: runForThreadInput.threadId, + projectId: runForThreadInput.projectId, + projectCwd: runForThreadInput.projectCwd, + worktreePath: runForThreadInput.worktreePath, + }, + { + threadId: ThreadId.make("thread-bootstrap"), + projectId: defaultProjectId, + projectCwd: "/tmp/project", + worktreePath: "/tmp/bootstrap-worktree", + }, + ); + // Worktree bootstraps observe script completion so the setup card can show the exit code. + assert.isDefined(runForThreadInput?.observeCompletion); assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); const setupActivities = dispatchedCommands.filter( @@ -11111,6 +11127,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: "started" as const, scriptId: "setup", scriptName: "Setup", + scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b8c3ca6096e9..189d62dd8362 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeHttp from "node:http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, ProviderDriverKind, @@ -29,6 +34,7 @@ import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -104,6 +110,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; @@ -163,17 +170,7 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe( Layer.provideMerge(ResourceAttributionLayerLive), ); -const PtyAdapterLive = Layer.unwrap( - Effect.gen(function* () { - if (typeof Bun !== "undefined") { - const BunPtyAdapter = yield* Effect.promise(() => import("./terminal/BunPtyAdapter.ts")); - return BunPtyAdapter.layer; - } else { - const NodePtyAdapter = yield* Effect.promise(() => import("./terminal/NodePtyAdapter.ts")); - return NodePtyAdapter.layer; - } - }), -); +const PtyAdapterLive = NodePtyAdapter.layer; const ServerSettingsLayerLive = ServerSettings.layer.pipe( Layer.provide(ServerSecretStore.layer), @@ -226,61 +223,22 @@ const RelayClientLive = Layer.unwrap( const HttpServerLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - if (typeof Bun !== "undefined") { - const BunHttpServer = yield* Effect.promise( - () => import("@effect/platform-bun/BunHttpServer"), - ); - return BunHttpServer.layer({ - port: config.port, - hostname: config.host ?? "127.0.0.1", - gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, - websocket: { - // Negotiate permessage-deflate with clients that offer it; clients - // that don't still get uncompressed frames on their connection. A - // dedicated compressor keeps a per-connection sliding window - // (context takeover) so the compression dictionary is shared across - // server-to-client frames. Decompression uses the shared - // decompressor: uWebSockets' dedicated decompressor path can abort - // connections (close 1006) on valid DEFLATE input — see - // https://github.com/uNetworking/uWebSockets.js/issues/633. - perMessageDeflate: { - compress: "dedicated", - decompress: "shared", - }, - }, - }); - } else { - const [NodeHttpServer, NodeHttp] = yield* Effect.all([ - Effect.promise(() => import("@effect/platform-node/NodeHttpServer")), - Effect.promise(() => import("node:http")), - ]); - return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), { - host: config.host ?? "127.0.0.1", - port: config.port, - gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, - // Negotiate permessage-deflate with clients that offer it; clients - // that don't still get uncompressed frames on their connection. - // Context takeover stays enabled (ws default) so the compression - // window is shared across frames — that also makes small frames cheap - // to compress, so no size threshold is set (ws only honors - // `threshold` when context takeover is disabled). - websocket: { perMessageDeflate: true }, - }); - } + return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), { + host: config.host ?? "127.0.0.1", + port: config.port, + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + // Negotiate permessage-deflate with clients that offer it; clients + // that don't still get uncompressed frames on their connection. + // Context takeover stays enabled (ws default) so the compression + // window is shared across frames — that also makes small frames cheap + // to compress, so no size threshold is set (ws only honors + // `threshold` when context takeover is disabled). + websocket: { perMessageDeflate: true }, + }); }), ); -const PlatformServicesLive = Layer.unwrap( - Effect.gen(function* () { - if (typeof Bun !== "undefined") { - const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices")); - return layer; - } else { - const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices")); - return layer; - } - }), -); +const PlatformServicesLive = NodeServices.layer; const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), @@ -375,6 +333,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), + Layer.provideMerge(WorktreeSetupTracker.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -656,9 +615,11 @@ const makeServerLayer = Layer.unwrap( return; } + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const state = yield* makePersistedServerRuntimeState({ config, port: address.port, + serviceManaged: launcher.managed, }); yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, @@ -818,7 +779,7 @@ const makeServerLayer = Layer.unwrap( const serverApplicationLayer = Layer.mergeAll( routesLayer, httpListeningLayer, - runtimeStateLayer, + runtimeStateLayer.pipe(Layer.provide(launcherLayer)), tailscaleServeLayer, cloudDesiredLinkReconcileLayer, ); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 4c2375b29a74..6fbdab817b63 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -62,6 +62,25 @@ describe("serverRuntimeState", () => { }), ); + it.effect("marks a service-supervised server so CLIs can tell it from a manual one", () => + Effect.gen(function* () { + const managed = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 13_773, + serviceManaged: true, + }); + const manual = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 13_773, + }); + + assert.isTrue(managed.serviceManaged); + // Older readers decode the file without the field, so it is omitted + // rather than written as false. + assert.isFalse("serviceManaged" in manual); + }), + ); + it.effect("treats a missing runtime state file as absent", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index ac81941bb6f6..4afe10bd5b65 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -18,6 +18,12 @@ export const PersistedServerRuntimeState = Schema.Struct({ // Dev is single-origin: browsers must pair through this URL, not `origin`. devUrl: Schema.optional(Schema.String), startedAt: Schema.String, + /** + * Set when the boot-service launcher supervises this server. Lets a CLI + * tell a service-managed server apart from one started by hand, which is + * the difference between "restart the service" and "stop your terminal". + */ + serviceManaged: Schema.optional(Schema.Boolean), }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -50,6 +56,7 @@ const runtimeOriginForConfig = ( export const makePersistedServerRuntimeState = (input: { readonly config: Pick; readonly port: number; + readonly serviceManaged?: boolean; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ version: 1, @@ -59,6 +66,7 @@ export const makePersistedServerRuntimeState = (input: { origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), + ...(input.serviceManaged ? { serviceManaged: true } : {}), })); export const persistServerRuntimeState = (input: { diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts deleted file mode 100644 index 105212451629..000000000000 --- a/apps/server/src/service-launcher.ts +++ /dev/null @@ -1 +0,0 @@ -import "./serviceLauncher.ts"; diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 45c472af1fc4..30b239f3e47f 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -10,6 +10,7 @@ import { decodeServiceState, isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL, + SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; @@ -75,6 +76,27 @@ it("rejects contradictory service state", () => { ); }); +// A pinned runtime is an executable at /t3. The tests stand one up +// as a Node shebang script so the launcher spawns it the way it spawns the +// real single-executable, IPC channel included. +const writeFakeRuntime = ( + fs: FileSystem.FileSystem, + path: Path.Path, + versionDir: string, + childSource: string, +) => + Effect.gen(function* () { + const entryPath = path.join(versionDir, "t3"); + yield* fs.makeDirectory(versionDir, { recursive: true }); + yield* fs.writeFileString(entryPath, `#!${process.execPath}\n${childSource}`); + yield* fs.chmod(entryPath, 0o755); + yield* fs.writeFileString( + path.join(versionDir, ".install-complete"), + `${path.basename(versionDir)}\n`, + ); + return entryPath; + }); + it.layer(NodeServices.layer)("service state persistence", (it) => { it.effect("durably replaces and strictly reads one state document", () => Effect.gen(function* () { @@ -92,17 +114,62 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); + it.effect("a fresh launcher clears a restart deferred by t3 update", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-restart-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const restartPending = path.join(root, "runtime", SERVICE_RESTART_PENDING_FILE); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + const run = () => + Effect.gen(function* () { + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + ); + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + + // A launcher that is still the old version leaves a marker that waits + // for a newer one. + yield* fs.writeFileString(restartPending, "1.0.1\n"); + yield* run(); + assert.isTrue(yield* fs.exists(restartPending)); + + // Whoever restarted the service, the launcher now runs what the unit + // names, so the deferred-restart marker is gone. + yield* fs.writeFileString(restartPending, "1.0.0\n"); + yield* run(); + assert.isFalse(yield* fs.exists(restartPending)); + }), + ); + it.effect("serializes shutdown with launcher recovery", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); const statePath = path.join(root, "runtime", "service-state.json"); - const versionDir = path.join(root, "runtime", "versions", "1.0.0"); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n"); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); yield* Effect.promise(() => writeServiceState(statePath, { protocol: SERVICE_LAUNCHER_PROTOCOL, @@ -148,11 +215,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -198,11 +266,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -257,11 +326,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index e912130c8939..cfdd3d75b57e 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,7 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics globalTimers:off -// This file is shipped as a standalone bundle and copied to a stable path by -// `t3 service update`. Keep runtime imports limited to Node built-ins. +// The launcher supervises the server child for the boot service and must keep +// working across server versions, so it stays on Node built-ins with no Effect +// runtime: it is the one part of the executable that cannot depend on the +// rest of it being loadable. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; @@ -24,9 +26,9 @@ import { SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, + SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; -import { isEntrypoint } from "./entrypoint.ts"; const HANDOFF_DELAY_MS = 2_000; const PREPARED_TIMEOUT_MS = 120_000; @@ -41,15 +43,25 @@ interface ManagedChild { readonly process: NodeChildProcess.ChildProcess; } +// Mirrors pinnedRuntimePaths: a runtime is an unpacked release archive whose +// executable runs on its own. Kept inline so this file stays on Node +// built-ins only. const runtimePaths = (baseDir: string, version: string) => { const versionDir = NodePath.join(baseDir, "runtime", "versions", version); + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. + const executableName = process.platform === "win32" ? "t3.exe" : "t3"; return { versionDir, - entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: NodePath.join(versionDir, executableName), sentinelPath: NodePath.join(versionDir, ".install-complete"), }; }; +const runtimeSpawnArguments = (paths: ReturnType) => ({ + command: paths.entryPath, + args: ["serve"], +}); + /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; const RESTORE_MARKER = ".restore-pending"; @@ -262,6 +274,8 @@ async function terminateChild( const stopMarkerPath = (baseDir: string) => NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE); +const restartPendingPath = (baseDir: string) => + NodePath.join(baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); export class Launcher { readonly #baseDir: string; @@ -354,8 +368,17 @@ export class Launcher { async #recover(): Promise { // A fresh launcher means servers are running again: any stop marker from // a previous explicit stop is stale and must not make a future update - // handoff release its tunnel. + // handoff release its tunnel. A restart deferred by `t3 update` is done + // no matter who restarted the service, but only once this launcher is + // the version the marker waits for: a launcher that came up between the + // CLI writing the marker and writing the new state still runs the old + // version, and the marker has to outlive it. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); + const restartPending = restartPendingPath(this.#baseDir); + const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); + if (awaitedVersion?.trim() === this.#state.activeVersion) { + await NodeFSP.rm(restartPending, { force: true }).catch(() => undefined); + } const update = this.#state.update; if (update?.status !== "pending") { if (update !== undefined) { @@ -402,7 +425,8 @@ export class Launcher { childVersion: version, ...(update === undefined ? {} : { update }), }; - const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], { + const spawnArguments = runtimeSpawnArguments(paths); + const child = NodeChildProcess.spawn(spawnArguments.command, spawnArguments.args, { env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, stdio: ["inherit", "inherit", "inherit", "ipc"], }); @@ -602,7 +626,7 @@ export class Launcher { } } -async function main(): Promise { +export async function main(): Promise { const baseDir = process.env.T3CODE_HOME?.trim(); if (baseDir === undefined || baseDir === "") { throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); @@ -611,17 +635,3 @@ async function main(): Promise { const state = await readServiceState(statePath); await new Launcher(baseDir, state).run(); } - -if ( - isEntrypoint({ - moduleUrl: import.meta.url, - entryPath: process.argv[1], - runtimeMain: import.meta.main, - }) -) { - main().catch((cause: unknown) => { - const error = cause instanceof Error ? cause : new Error(String(cause)); - process.stderr.write(`[service-launcher] ${error.message}\n`); - process.exitCode = 1; - }); -} diff --git a/apps/server/src/terminal/BunPtyAdapter.test.ts b/apps/server/src/terminal/BunPtyAdapter.test.ts deleted file mode 100644 index e04a54e6d333..000000000000 --- a/apps/server/src/terminal/BunPtyAdapter.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { assert, expect, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; - -import * as BunPtyAdapter from "./BunPtyAdapter.ts"; - -it("describes unavailable Bun PTY operations structurally", () => { - const error = new BunPtyAdapter.BunPtyOperationUnavailableError({ - operation: "resize", - pid: 42, - }); - - expect(error).toMatchObject({ - _tag: "BunPtyOperationUnavailableError", - operation: "resize", - pid: 42, - }); - expect(error.message).toBe("Bun PTY resize is unavailable for process 42."); -}); - -it.effect("reports unsupported platforms with a structured startup defect", () => - Effect.gen(function* () { - const exit = yield* BunPtyAdapter.make().pipe( - Effect.provideService(HostProcessPlatform, "win32"), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.hasDies(exit.cause)).toBe(true); - const error = Cause.squash(exit.cause); - assert.instanceOf(error, BunPtyAdapter.BunPtyUnsupportedPlatformError); - expect(error).toMatchObject({ - _tag: "BunPtyUnsupportedPlatformError", - platform: "win32", - }); - expect(error.message).toBe( - "Bun PTY terminal support is unavailable on win32. Please use Node.js (e.g. by running `npx t3`) instead.", - ); - } - }), -); diff --git a/apps/server/src/terminal/BunPtyAdapter.ts b/apps/server/src/terminal/BunPtyAdapter.ts deleted file mode 100644 index 1a3f26ceb670..000000000000 --- a/apps/server/src/terminal/BunPtyAdapter.ts +++ /dev/null @@ -1,155 +0,0 @@ -/// - -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; - -import * as PtyAdapter from "./PtyAdapter.ts"; - -export class BunPtyUnsupportedPlatformError extends Schema.TaggedError()( - "BunPtyUnsupportedPlatformError", - { - platform: Schema.Literal("win32"), - }, -) { - override get message(): string { - return `Bun PTY terminal support is unavailable on ${this.platform}. Please use Node.js (e.g. by running \`npx t3\`) instead.`; - } -} - -export class BunPtyOperationUnavailableError extends Schema.TaggedError()( - "BunPtyOperationUnavailableError", - { - operation: Schema.Literals(["write", "resize"]), - pid: Schema.Number, - }, -) { - override get message(): string { - return `Bun PTY ${this.operation} is unavailable for process ${this.pid}.`; - } -} - -class BunPtyProcess implements PtyAdapter.PtyProcess { - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); - private readonly decoder = new TextDecoder(); - private readonly process: Bun.Subprocess; - private didExit = false; - - constructor(process: Bun.Subprocess) { - this.process = process; - void this.process.exited - .then((exitCode) => { - this.emitExit({ - exitCode: Number.isInteger(exitCode) ? exitCode : 0, - signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, - }); - }) - .catch(() => { - this.emitExit({ exitCode: 1, signal: null }); - }); - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - if (!this.process.terminal) { - throw new BunPtyOperationUnavailableError({ operation: "write", pid: this.pid }); - } - this.process.terminal.write(data); - } - - resize(cols: number, rows: number): void { - if (!this.process.terminal?.resize) { - throw new BunPtyOperationUnavailableError({ operation: "resize", pid: this.pid }); - } - this.process.terminal.resize(cols, rows); - } - - kill(signal?: string): void { - if (!signal) { - this.process.kill(); - return; - } - this.process.kill(signal as NodeJS.Signals); - } - - onData(callback: (data: string) => void): () => void { - this.dataListeners.add(callback); - return () => { - this.dataListeners.delete(callback); - }; - } - - onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - emitData(data: Uint8Array): void { - if (this.didExit) return; - const text = this.decoder.decode(data, { stream: true }); - if (text.length === 0) return; - for (const listener of this.dataListeners) { - listener(text); - } - } - - private emitExit(event: PtyAdapter.PtyExitEvent): void { - if (this.didExit) return; - this.didExit = true; - - const remainder = this.decoder.decode(); - if (remainder.length > 0) { - for (const listener of this.dataListeners) { - listener(remainder); - } - } - - for (const listener of this.exitListeners) { - listener(event); - } - } -} - -export const make = Effect.fn("BunPtyAdapter.make")(function* () { - const platform = yield* HostProcessPlatform; - if (platform === "win32") { - return yield* Effect.die(new BunPtyUnsupportedPlatformError({ platform })); - } - return PtyAdapter.PtyAdapter.of({ - spawn: (input) => - Effect.try({ - try: () => { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); - }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return processHandle; - }, - catch: (cause) => - new PtyAdapter.PtySpawnError({ - adapter: "bun", - shell: input.shell, - cause, - }), - }), - }); -}); - -export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 066cf01f261a..e6650025f70f 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -19,7 +19,7 @@ const spawn = vi.fn(() => ({ onExit: vi.fn(() => ({ dispose: vi.fn() })), })); -vi.mock("node-pty", () => ({ spawn })); +const fakeNodePty = { spawn } as unknown as typeof import("node-pty"); const makeTestLayer = (platform: NodeJS.Platform = "win32") => NodePtyAdapter.layer.pipe( @@ -28,6 +28,7 @@ const makeTestLayer = (platform: NodeJS.Platform = "win32") => NodeServices.layer, Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.resolve(fakeNodePty)), ), ), ); @@ -125,7 +126,10 @@ it.effect("preserves a caller-provided TERM in the spawn env on win32", () => it.effect("reports native module load failures as structured startup defects", () => Effect.gen(function* () { const cause = new Error("native binding could not be loaded"); - const exit = yield* NodePtyAdapter.make(() => Promise.reject(cause)).pipe(Effect.exit); + const exit = yield* NodePtyAdapter.make().pipe( + Effect.provideService(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.reject(cause)), + Effect.exit, + ); assert.isTrue(Exit.isFailure(exit)); if (Exit.isFailure(exit)) { diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 8f238ac60c34..67cdcecdd53a 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,5 +1,6 @@ import * as NodeModule from "node:module"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -24,10 +25,24 @@ export class NodePtyModuleLoadError extends Schema.TaggedError Promise; +// node-pty stays external to the CLI bundle because it dlopens a native +// addon. Inside a Node single-executable, `import()` cannot load files from +// disk (only built-ins resolve), while `require` always reads the real +// filesystem, so both the module and its spawn-helper resolve through it. +const requireForNodePty = NodeModule.createRequire(import.meta.url); + +const loadNodePty: NodePtyModuleLoader = () => + Promise.resolve().then(() => requireForNodePty("node-pty") as typeof import("node-pty")); + +/** Injectable so tests can substitute a fake module; `require` bypasses module mocks. */ +export const NodePtyModuleLoaderRef = Context.Reference( + "server/terminal/NodePtyModuleLoader", + { defaultValue: () => loadNodePty }, +); + let didEnsureSpawnHelperExecutable = false; const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const requireForNodePty = NodeModule.createRequire(import.meta.url); const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; @@ -113,9 +128,8 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } } -export const make = Effect.fn("NodePtyAdapter.make")(function* ( - loadNodePtyModule: NodePtyModuleLoader = () => import("node-pty"), -) { +export const make = Effect.fn("NodePtyAdapter.make")(function* () { + const loadNodePtyModule = yield* NodePtyModuleLoaderRef; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 9b25e915973c..4d63a447d63f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -102,6 +102,36 @@ export interface ExecuteGitProgress { }) => Effect.Effect; } +/** + * Progress callbacks for `createWorktree`. Git prints `Updating files: 78% (2104/2700)` + * to stderr during checkout, and `Submodule path 'x': checked out` during + * submodule init. The tracker uses these to drive the worktree setup card. + */ +export interface CreateWorktreeProgress { + /** + * Fires once `git worktree add` has created and registered the directory, + * before the (possibly long) submodule step. Git refuses an existing path, + * so a path reported here belongs to this call and is safe to remove on + * cancel. + */ + readonly onWorktreeClaimed?: (path: string) => Effect.Effect; + readonly onCheckoutProgress?: (input: { + percent: number; + completed: number; + total: number; + }) => Effect.Effect; + readonly onSubmodulesStarted?: () => Effect.Effect; + readonly onSubmoduleLine?: (line: string) => Effect.Effect; + readonly onSubmodulesFinished?: (input: { + ok: boolean; + detail: string | null; + }) => Effect.Effect; +} + +export interface CreateWorktreeOptions { + readonly progress?: CreateWorktreeProgress; +} + export interface GitCommitProgress { readonly onOutputLine?: (input: { stream: "stdout" | "stderr"; @@ -280,6 +310,7 @@ export class GitVcsDriver extends Context.Service< readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, + options?: CreateWorktreeOptions, ) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4f1f0204a4ed..bc8a12700997 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -20,7 +20,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + makeGitVcsDriverCore, + parseGitCheckoutProgressLine, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -1553,6 +1557,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it("parses checkout progress lines from git's stderr", () => { + assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { + percent: 78, + completed: 2104, + total: 2700, + }); + // Progress lines arrive carriage-return separated and end with a done marker. + assert.deepStrictEqual( + parseGitCheckoutProgressLine("Updating files: 100% (2700/2700), done."), + { percent: 100, completed: 2700, total: 2700 }, + ); + assert.strictEqual(parseGitCheckoutProgressLine("Preparing worktree (new branch 'x')"), null); + }); + // NTFS rejects a newline in a file name, so there is nothing to preserve there. it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "preserves newline characters in worktree paths when listing refs", @@ -1668,6 +1686,53 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports checkout progress while creating a worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + for (let index = 0; index < 5; index += 1) { + yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "progress-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const seen = yield* Ref.make>( + [], + ); + const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null); + + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" }, + { + progress: { + onWorktreeClaimed: (path) => + Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }), + onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]), + }, + }, + ); + // Claimed only once git has registered the directory. + assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true }); + + // Git separates live progress updates with `\r`, so the driver must + // surface every intermediate percentage, not just the final line. + const updates = yield* Ref.get(seen); + assert.isAbove(updates.length, 1); + assert.equal(updates.at(-1)?.percent, 100); + assert.equal(updates.at(-1)?.total, 6); + const completed = updates.map((update) => update.completed); + assert.deepEqual( + completed, + completed.toSorted((a, b) => a - b), + ); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d371e63617f6..86a3e2ebd812 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -641,6 +641,25 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( }; }); +const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/; + +/** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */ +export function parseGitCheckoutProgressLine( + line: string, +): { percent: number; completed: number; total: number } | null { + const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line); + if (!match) return null; + const percent = Number(match[1]); + const completed = Number(match[2]); + const total = Number(match[3]); + if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) { + return null; + } + return { percent: Math.max(0, Math.min(100, percent)), completed, total }; +} + +const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/; + const collectOutput = Effect.fnUntraced(function* ( input: Pick, stream: Stream.Stream, @@ -654,19 +673,21 @@ const collectOutput = Effect.fnUntraced(function* ( let lineBuffer = ""; let truncated = false; + // Git redraws progress with a bare `\r` between updates and only ends the + // line once the step is done, so `\r` has to count as a line break here. const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { - let newlineIndex = lineBuffer.indexOf("\n"); - while (newlineIndex >= 0) { - const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); - lineBuffer = lineBuffer.slice(newlineIndex + 1); + let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); + while (separator) { + const line = lineBuffer.slice(0, separator.index); + lineBuffer = lineBuffer.slice(separator.index + separator[0].length); if (line.length > 0 && onLine) { yield* onLine(line); } - newlineIndex = lineBuffer.indexOf("\n"); + separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); } if (flush) { - const trailing = lineBuffer.replace(/\r$/, ""); + const trailing = lineBuffer; lineBuffer = ""; if (trailing.length > 0 && onLine) { yield* onLine(trailing); @@ -3010,7 +3031,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", - )(function* (input) { + )(function* (input, options) { const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); @@ -3018,12 +3039,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; + const progress = options?.progress; + const onCheckoutProgress = progress?.onCheckoutProgress; yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorDetail: "git worktree add failed", timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + ...(onCheckoutProgress + ? { + // Git only prints checkout progress when stderr is a tty or the + // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. + env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, + progress: { + onStderrLine: (line) => { + const parsed = parseGitCheckoutProgressLine(line); + return parsed ? onCheckoutProgress(parsed) : Effect.void; + }, + }, + } + : {}), }); + if (progress?.onWorktreeClaimed) { + yield* progress.onWorktreeClaimed(worktreePath); + } + // `git worktree add` leaves submodules empty, so a repo that keeps agent // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's @@ -3033,18 +3073,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); if (hasSubmodules) { - yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ - "submodule", - "update", - "--init", - "--recursive", - ]).pipe( - Effect.catch((cause) => - Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { - worktreePath, - cause, - }), - ), + if (progress?.onSubmodulesStarted) { + yield* progress.onSubmodulesStarted(); + } + const onSubmoduleLine = progress?.onSubmoduleLine; + yield* runGit( + "GitVcsDriver.createWorktree.updateSubmodules", + worktreePath, + ["submodule", "update", "--init", "--recursive"], + onSubmoduleLine + ? { + env: { LC_ALL: "C" }, + progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + } + : {}, + ).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }).pipe( + Effect.andThen( + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: false, detail: cause.message }) + : Effect.void, + ), + ), + onSuccess: () => + progress?.onSubmodulesFinished + ? progress.onSubmodulesFinished({ ok: true, detail: null }) + : Effect.void, + }), ); } @@ -3496,7 +3556,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, - createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + createWorktree: (input, options) => + withListRefsInvalidation(input.cwd, createWorktree(input, options)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), fetchPullRequestHeadCommit, diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 44a9c3397c6f..cac501aede0c 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,13 +1,15 @@ -import { - type DirItem, - type DirSearchResult, - type FileItem, - FileFinder, - type GrepCursor, - type MixedItem, - type MixedSearchResult, - type Result, - type SearchResult, +import * as NodeModule from "node:module"; + +import type { + DirItem, + DirSearchResult, + FileItem, + FileFinder as FileFinderType, + GrepCursor, + MixedItem, + MixedSearchResult, + Result, + SearchResult, } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -25,6 +27,13 @@ import type { } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +// fff-node stays external to the CLI bundle because it dlopens a native +// library. A static `import` of an external package is a hard error inside a +// Node single-executable (only built-ins resolve there), so load it through +// `require`, which reads from the real filesystem in every runtime. +const requireForFff = NodeModule.createRequire(import.meta.url); +const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); + const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; @@ -330,7 +339,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( cwd: string, - finder: FileFinder, + finder: FileFinderType, onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, ): Effect.fn.Return { const result = yield* Effect.tryPromise({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1f960c488d8a..0e628879712a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -7,11 +7,13 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; 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 Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { @@ -115,6 +117,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; @@ -129,6 +132,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -545,6 +549,8 @@ const makeWsRpcLayer = ( const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; const deviceService = yield* DeviceService.DeviceService; + const deviceHostContext = + yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; @@ -597,6 +603,7 @@ const makeWsRpcLayer = ( return true; }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -991,6 +998,9 @@ const makeWsRpcLayer = ( let targetProjectId = bootstrap?.createThread?.projectId; let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + // The setup script's terminal, once started. Cancel closes only this + // one so terminals the user opened meanwhile survive. + let setupTerminalId: string | null = null; const cleanupCreatedThread = () => createdThread @@ -1083,19 +1093,37 @@ const makeWsRpcLayer = ( ); }); + const tracked = bootstrap?.prepareWorktree !== undefined; + const threadId = command.threadId; + const track = (effect: Effect.Effect) => (tracked ? effect : Effect.void); + + // Runs the setup script and, for tracked bootstraps, waits for it to + // exit so the card can show the exit code and the agent stage never + // starts on a half-installed tree. Untracked callers keep the old + // fire-and-forget behavior. const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { + yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped")); return; } const worktreePath = targetWorktreePath; const requestedAt = yield* nowIso; - yield* projectSetupScriptRunner + yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "running")); + const setupResult = yield* projectSetupScriptRunner .runForThread({ - threadId: command.threadId, + threadId, ...(targetProjectId ? { projectId: targetProjectId } : {}), ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), worktreePath, + ...(tracked + ? { + observeCompletion: { + onOutputLine: (line) => + worktreeSetupTracker.appendTail(threadId, "setup-script", line), + }, + } + : {}), }) .pipe( Effect.matchEffect({ @@ -1104,21 +1132,71 @@ const makeWsRpcLayer = ( error, requestedAt, worktreePath, - }), + }).pipe( + Effect.andThen( + track( + worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "failed", + "failed to start", + ), + ), + ), + Effect.as(null), + ), onSuccess: (setupResult) => { if (setupResult.status !== "started") { - return Effect.void; + return track( + worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "skipped", + "no setup script", + ), + ).pipe(Effect.as(null)); } + setupTerminalId = setupResult.terminalId; return recordSetupScriptStarted({ requestedAt, worktreePath, scriptId: setupResult.scriptId, scriptName: setupResult.scriptName, terminalId: setupResult.terminalId, - }); + }).pipe( + Effect.andThen( + track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + setupScript: { + name: setupResult.scriptName, + command: setupResult.scriptCommand, + terminalId: setupResult.terminalId, + }, + })), + ), + ), + Effect.as(setupResult), + ); }, }), ); + if (!tracked || !setupResult?.completion) { + return; + } + // The setup script is best effort, like the untracked path: a + // failed install must not throw away the worktree the user just + // waited for. The card keeps the failed stage and its terminal. + const completion = yield* setupResult.completion; + if (completion.exitCode === 0) { + yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "done"); + return; + } + const detail = + completion.exitCode === null + ? "terminal closed before the script finished" + : `exit ${completion.exitCode}`; + yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "failed", detail); }); const bootstrapProgram = Effect.gen(function* () { @@ -1138,6 +1216,7 @@ const makeWsRpcLayer = ( remoteName: "origin", })); if (startFromOrigin) { + yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "running")); yield* gitWorkflow.fetchRemote({ cwd: prepareWorktree.projectCwd, remoteName: "origin", @@ -1154,7 +1233,26 @@ const makeWsRpcLayer = ( fallbackRemoteName: "origin", }); worktreeBaseRef = resolvedRemoteBase.commitSha; + yield* track( + worktreeSetupTracker.stageStatus( + threadId, + "fetch", + "done", + `origin/${prepareWorktree.baseBranch} at ${resolvedRemoteBase.commitSha.slice(0, 7)}`, + ), + ); + } else { + yield* track( + worktreeSetupTracker.stageStatus( + threadId, + "fetch", + "warning", + `origin/${prepareWorktree.baseBranch} not found, using local branch`, + ), + ); } + } else { + yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "skipped")); } const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch; @@ -1163,6 +1261,27 @@ const makeWsRpcLayer = ( refName: resolvedWorktreeBaseRef, }); worktreeBaseRef = resolvedWorktreeBaseRef; + yield* track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + baseRef: resolvedWorktreeBaseRef, + })), + ); + } + + if (prepareWorktree && !shouldPrepareWorktree) { + // Not a git repo, or the base has no commit: the thread runs in + // the project checkout instead. The card says so and moves on. + yield* track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + stages: snapshot.stages.map((stage) => + stage.id === "fetch" || stage.id === "checkout" || stage.id === "submodules" + ? { ...stage, status: "skipped", detail: "using project checkout" } + : stage, + ), + })), + ); } if (bootstrap?.createThread) { @@ -1188,18 +1307,92 @@ const makeWsRpcLayer = ( } if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { - const worktree = yield* gitWorkflow.createWorktree({ - cwd: prepareWorktree.projectCwd, - refName: worktreeBaseRef, - newRefName: prepareWorktree.branch, - baseRefName: prepareWorktree.baseBranch, - path: null, - }); + yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); + let checkoutTotal: number | null = null; + const worktree = yield* gitWorkflow.createWorktree( + { + cwd: prepareWorktree.projectCwd, + refName: worktreeBaseRef, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, + path: null, + }, + { + progress: { + // Git has registered the directory at this point, so a + // cancel during the submodule step can still remove it. + onWorktreeClaimed: (path) => + Effect.sync(() => { + targetWorktreePath = path; + }), + onCheckoutProgress: ({ percent, completed, total }) => { + checkoutTotal = total; + return worktreeSetupTracker.stage(threadId, "checkout", { + percent, + detail: `${completed.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} files`, + }); + }, + onSubmodulesStarted: () => + worktreeSetupTracker + .stageStatus( + threadId, + "checkout", + "done", + checkoutTotal === null + ? null + : `${checkoutTotal.toLocaleString("en-US")} files`, + ) + .pipe( + Effect.andThen( + worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), + ), + ), + onSubmoduleLine: (line) => { + const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; + return submodulePath === undefined + ? Effect.void + : worktreeSetupTracker.stage(threadId, "submodules", { + detail: submodulePath, + }); + }, + onSubmodulesFinished: ({ ok, detail }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + ok ? "done" : "warning", + ok ? undefined : (detail ?? "submodule checkout failed"), + ), + }, + }, + ); + const checkoutEndedAt = yield* nowIso; + yield* worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + worktreePath: worktree.worktree.path, + stages: snapshot.stages.map((stage) => { + if (stage.id === "checkout" && stage.status === "running") { + return { + ...stage, + status: "done", + percent: 100, + endedAt: checkoutEndedAt, + detail: + checkoutTotal === null + ? stage.detail + : `${checkoutTotal.toLocaleString("en-US")} files`, + }; + } + if (stage.id === "submodules" && stage.status === "pending") { + return { ...stage, status: "skipped", detail: "none" }; + } + return stage; + }), + })); targetWorktreePath = worktree.worktree.path; yield* dispatchFromClient({ type: "thread.meta.update", commandId: yield* serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, + threadId, branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); @@ -1208,36 +1401,114 @@ const makeWsRpcLayer = ( yield* runSetupProgram(); - return yield* dispatchFromClient(finalTurnStartCommand); + yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running")); + // Past this point a cancel would roll back a thread whose turn has + // started. Drop the cancel handle and make the handoff atomic. + yield* track(worktreeSetupTracker.markUncancellable(threadId)); + const started = yield* Effect.uninterruptible( + dispatchFromClient(finalTurnStartCommand), + ); + yield* track( + worktreeSetupTracker + .stageStatus(threadId, "agent", "done") + .pipe(Effect.andThen(worktreeSetupTracker.finish(threadId, "done"))), + ); + return started; }); - return yield* bootstrapProgram.pipe( + const runBootstrap = tracked + ? Effect.gen(function* () { + const fiber = yield* Effect.forkChild(bootstrapProgram); + yield* worktreeSetupTracker.begin({ + threadId, + branch: bootstrap?.prepareWorktree?.branch ?? null, + baseRef: bootstrap?.prepareWorktree?.baseBranch ?? null, + stages: ["fetch", "checkout", "submodules", "setup-script", "agent"], + fiber, + }); + return yield* Fiber.join(fiber); + }) + : bootstrapProgram; + + const cleanupAndFail = ( + cause: Cause.Cause, + dispatchError: OrchestrationDispatchCommandError, + ) => + Effect.uninterruptible(cleanupCreatedThread()).pipe( + Effect.matchCauseEffect({ + onFailure: (cleanupCause) => + Effect.logWarning("bootstrap thread cleanup failed", { + threadId, + detail: Cause.pretty(cleanupCause), + }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), + onSuccess: (threadDeleted) => + Effect.fail( + threadDeleted + ? new OrchestrationDispatchCommandError({ + message: dispatchError.message, + ...(dispatchError.cause !== undefined + ? { cause: dispatchError.cause } + : {}), + bootstrapThreadDisposition: "deleted", + }) + : dispatchError, + ), + }), + ); + + return yield* runBootstrap.pipe( Effect.catchCause((cause) => { const dispatchError = toBootstrapDispatchCommandCauseError(cause); if (Cause.hasInterruptsOnly(cause)) { - return Effect.fail(dispatchError); + // A user cancel interrupts the forked bootstrap fiber. The + // created thread is rolled back like any other failure so the + // draft returns to the composer. The setup terminal is closed + // first so a still-running script cannot hold files open in + // the worktree while git removes it. Closing kills the + // process asynchronously, so the removal retries briefly. + const closeSetupTerminal = setupTerminalId + ? terminalManager.close({ + threadId, + terminalId: setupTerminalId, + deleteHistory: true, + }) + : Effect.void; + const removeCreatedWorktree = + tracked && targetWorktreePath && bootstrap?.prepareWorktree + ? closeSetupTerminal.pipe( + Effect.ignoreCause({ log: true }), + Effect.andThen( + gitWorkflow + .removeWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + path: targetWorktreePath, + force: true, + }) + .pipe( + Effect.retry({ times: 4, schedule: Schedule.spaced("500 millis") }), + ), + ), + Effect.ignoreCause({ log: true }), + Effect.uninterruptible, + ) + : Effect.void; + return track(worktreeSetupTracker.finish(threadId, "cancelled")).pipe( + Effect.andThen(removeCreatedWorktree), + Effect.andThen( + tracked + ? cleanupAndFail( + cause, + new OrchestrationDispatchCommandError({ + message: "Worktree setup cancelled.", + }), + ) + : Effect.fail(dispatchError), + ), + ); } - return Effect.uninterruptible(cleanupCreatedThread()).pipe( - Effect.matchCauseEffect({ - onFailure: (cleanupCause) => - Effect.logWarning("bootstrap thread cleanup failed", { - threadId: command.threadId, - detail: Cause.pretty(cleanupCause), - }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), - onSuccess: (threadDeleted) => - Effect.fail( - threadDeleted - ? new OrchestrationDispatchCommandError({ - message: dispatchError.message, - ...(dispatchError.cause !== undefined - ? { cause: dispatchError.cause } - : {}), - bootstrapThreadDisposition: "deleted", - }) - : dispatchError, - ), - }), - ); + return track( + worktreeSetupTracker.finish(threadId, "failed", dispatchError.message), + ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError))); }), ); }); @@ -2037,9 +2308,18 @@ const makeWsRpcLayer = ( [WS_METHODS.serverUpdateSettings]: ({ patch }) => observeRpcEffect( WS_METHODS.serverUpdateSettings, - serverSettings - .updateSettings(patch) - .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), + Effect.gen(function* () { + const deviceHosts = patch.deviceHosts + ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( + Effect.provide(deviceHostContext), + ) + : undefined; + const settings = yield* serverSettings.updateSettings({ + ...patch, + ...(deviceHosts ? { deviceHosts } : {}), + }); + return ServerSettings.redactServerSettingsForClient(settings); + }), { "rpc.aggregate": "server", }, @@ -2614,6 +2894,20 @@ const makeWsRpcLayer = ( "rpc.aggregate": "vcs", }, ), + [WS_METHODS.subscribeWorktreeSetup]: (input) => + observeRpcStream( + WS_METHODS.subscribeWorktreeSetup, + worktreeSetupTracker.stream(input.threadId), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.worktreeSetupCancel]: (input) => + observeRpcEffect( + WS_METHODS.worktreeSetupCancel, + worktreeSetupTracker + .cancel(input.threadId) + .pipe(Effect.map((cancelled) => ({ cancelled }))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( WS_METHODS.vcsRefreshStatus, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 621a1f7bf66f..32a8be7e6daa 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -21,7 +21,46 @@ import { export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); -const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; +const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version) + ? "nightly" + : "latest"; + +// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe +// step refuses multi-chunk output and counts the sourcemap as a chunk, and the +// executable needs a host Node that supports `--build-sea` (25.7+), so this is +// a separate mode rather than a second entry in the default build. +const packExecutable = process.env.T3CODE_PACK_EXE === "1"; +// `-` in nodejs.org naming (darwin-x64, linux-arm64, win-x64). +// When set, tsdown injects the bundle into a downloaded Node of that target +// instead of the host Node, which is how the arm64 macOS runner produces the +// x64 archive. Cross-building is safe because the code cache is off. +// +// The Node inside the executable is pinned here rather than taken from the +// build host, so every archive of a release embeds the same runtime no matter +// which Node happens to run the build. +const SEA_NODE_VERSION = "26.8.2"; +const SEA_TARGETS = { + "darwin-arm64": { platform: "darwin", arch: "arm64" }, + "darwin-x64": { platform: "darwin", arch: "x64" }, + "linux-arm64": { platform: "linux", arch: "arm64" }, + "linux-x64": { platform: "linux", arch: "x64" }, + "win-arm64": { platform: "win", arch: "arm64" }, + "win-x64": { platform: "win", arch: "x64" }, +} as const; +const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); +if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) { + throw new Error( + `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`, + ); +} +const packExecutableTargets = packExecutableTarget + ? [ + { + ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], + nodeVersion: SEA_NODE_VERSION, + }, + ] + : undefined; export default mergeConfig( baseConfig, @@ -36,10 +75,26 @@ export default mergeConfig( }, }, pack: { - entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"], - outDir: "dist", - sourcemap: true, + // The executable embeds one entry; the history worker becomes a hidden + // subcommand there instead of a sibling script. + entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"], + outDir: packExecutable ? "dist-exe" : "dist", + sourcemap: !packExecutable, clean: true, + ...(packExecutable + ? { + exe: { + fileName: "t3", + outDir: "dist-exe", + ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), + // Node's SEA docs: `import()` does not work when useCodeCache is + // true, and the server reaches several modules that way. The + // cache is also platform-bound, so leaving it off keeps the + // build correct on any host. + seaConfig: { useCodeCache: false }, + }, + } + : {}), deps: { // Both halves are required. `alwaysBundle` forces the JS dependencies in // (declared deps are external by default, which is what this change is diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index dfe2b51d0400..cac54fe1ec84 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -506,6 +506,145 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(1); }); + it("exchanges a URL token when the browser already has a session", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + const testWindow = installTestBrowser("http://localhost/#token=reusable-token"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testWindow.location.hash).toBe(""); + }); + + it("exchanges an explicit pair link after caching an authenticated state", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + const testWindow = installTestBrowser("http://localhost/"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + + await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); + + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testApi.calls.session).toBe(3); + }); + + it("makes later callers wait for a URL token that arrives during bootstrap", async () => { + let releaseExchange!: () => void; + const exchangeRelease = new Promise((resolve) => { + releaseExchange = resolve; + }); + let markExchangeStarted!: () => void; + const exchangeStarted = new Promise((resolve) => { + markExchangeStarted = resolve; + }); + const nextSession = sequence( + authenticatedSession(LOOPBACK_AUTH), + authenticatedSession(LOOPBACK_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => { + markExchangeStarted(); + return Effect.promise(() => exchangeRelease).pipe( + Effect.andThen( + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-rejected-queued-credential", + }), + ), + ), + ); + }, + }); + const testWindow = installTestBrowser("http://localhost/"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const initialBootstrap = resolveInitialServerAuthGateState(); + testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + const explicitPairing = resolveInitialServerAuthGateState(); + const laterCaller = resolveInitialServerAuthGateState(); + let laterCallerSettled = false; + void laterCaller.then(() => { + laterCallerSettled = true; + }); + + try { + await expect(initialBootstrap).resolves.toEqual({ status: "authenticated" }); + await exchangeStarted; + expect(laterCallerSettled).toBe(false); + } finally { + releaseExchange(); + } + + const rejectedState = { + status: "requires-auth", + auth: LOOPBACK_AUTH, + errorMessage: "Invalid pairing token. Check the token and try again.", + } as const; + await expect(explicitPairing).resolves.toEqual(rejectedState); + await expect(laterCaller).resolves.toEqual(rejectedState); + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testApi.calls.session).toBe(2); + }); + + it("does not exchange a token during an ordinary authenticated load", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + + expect(testApi.calls.browserSession).toEqual([]); + }); + + it("reports a rejected URL token without caching false success", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-url-credential", + }); + const nextSession = sequence( + authenticatedSession(LOOPBACK_AUTH), + unauthenticatedSession(LOOPBACK_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => Effect.fail(cause), + }); + installTestBrowser("http://localhost/#token=rejected-token"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + errorMessage: "Invalid pairing token. Check the token and try again.", + }); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "rejected-token" }]); + }); + it("creates a pairing credential from the authenticated auth endpoint", async () => { const testApi = await installAuthApi({ pairingCredential: (payload) => diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6ab..126fb7706248 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -1,4 +1,4 @@ -const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const NIGHTLY_SERVER_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function formatAppDisplayName(input: { readonly baseName: string; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6497df68c8bd..6769586f7fa8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,11 +14,7 @@ import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalSt import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { - useCompactSidebarEnabled, - useEnvironmentIdentificationMode, - useLegacySidebarEnabled, -} from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -148,7 +144,6 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); - const compactSidebarEnabled = useCompactSidebarEnabled(); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); // Settings routes show the settings nav in place of whichever thread @@ -234,7 +229,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children}} > - {children}}> + {/* Reserve the block's height but stay hidden until Shiki has colored + it, so plain text never flashes before the highlighted version. */} + + {children} + + } + > diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index be279f220e7e..6f2a9e1199f8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -597,6 +597,30 @@ describe("draft hero submission transition", () => { ).toBe(false); }); + it("leaves the hero layout while a worktree setup card is on the timeline", () => { + expect( + resolveDraftHeroState({ + isLocalDraftThread: true, + hasTimelineEntries: false, + isWorking: false, + draftHeroDockRequested: false, + backgroundSubmissionPending: false, + hasWorktreeSetupCard: true, + }), + ).toBe(false); + // A background submission normally pins the hero, but never over the card. + expect( + resolveDraftHeroState({ + isLocalDraftThread: true, + hasTimelineEntries: false, + isWorking: false, + draftHeroDockRequested: false, + backgroundSubmissionPending: true, + hasWorktreeSetupCard: true, + }), + ).toBe(false); + }); + it("keeps the composer in the hero layout until navigation after server promotion", () => { expect( resolveDraftHeroState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ed1422a71109..eae137201d37 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -257,7 +257,12 @@ export function resolveDraftHeroState(input: { isWorking: boolean; draftHeroDockRequested: boolean; backgroundSubmissionPending: boolean; + /** A worktree setup card is on the timeline, so the timeline must stay visible. */ + hasWorktreeSetupCard?: boolean; }): boolean { + if (input.hasWorktreeSetupCard) { + return false; + } if (input.backgroundSubmissionPending) { return true; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7994b3196f58..574ed0f372a1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -43,6 +43,7 @@ import { resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; @@ -308,6 +309,8 @@ import { } from "../lib/composerContextRecords"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; @@ -1497,6 +1500,9 @@ export default function ChatView(props: ChatViewProps) { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { + reportFailure: false, + }); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], @@ -1640,6 +1646,27 @@ export default function ChatView(props: ChatViewProps) { return () => revokeBlobPreviewUrl(src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + // The bootstrap worktree setup this composer last dispatched. Set when a + // worktree send starts and cleared once the turn starts or the next send + // begins, so a failed or cancelled card stays until the user acts. + const [worktreeSetupRef, setWorktreeSetupRef] = useState<{ + environmentId: EnvironmentId; + threadId: ThreadId; + ownerKey: string; + } | null>(null); + const [heldWorktreeSetup, setHeldWorktreeSetup] = useState(null); + // Set by "Work locally": the draft whose restored message should be resent + // once the cancelled dispatch has settled and the draft is in local mode. + // Keyed by draft id so a bootstrap rotating the thread id keeps it, while + // moving to another draft drops it without an effect. + const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState(null); + // The draft route reuses this component across drafts, so a resend recorded + // for one draft must not fire when the user comes back to it later. + useEffect(() => { + if (workLocallyResendDraftId !== null && workLocallyResendDraftId !== draftId) { + setWorkLocallyResendDraftId(null); + } + }, [draftId, workLocallyResendDraftId]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record> >({}); @@ -2208,6 +2235,37 @@ export default function ChatView(props: ChatViewProps) { }, [retryEnvironment], ); + const disconnectDelayElapsed = useEnvironmentDisconnectDelay( + activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, + ); + const canDisconnectActiveEnvironment = + disconnectDelayElapsed && + activeEnvironment !== null && + activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && + !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); + const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); + const handleDisconnectActiveEnvironment = useCallback( + async (environmentId: EnvironmentId) => { + setDisconnectingEnvironment(true); + const result = await setEnvironmentEnabled({ environmentId, enabled: false }); + setDisconnectingEnvironment(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect server", + description: error instanceof Error ? error.message : "Failed to disconnect.", + }), + ); + } + return; + } + void navigate({ to: "/", replace: true }); + }, + [navigate, setEnvironmentEnabled], + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -2448,6 +2506,20 @@ export default function ChatView(props: ChatViewProps) { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const disconnectAction = + canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( + + ) : undefined; const environmentReconnecting = unavailableConnection !== null && (unavailableConnection.phase === "connecting" || @@ -2481,6 +2553,7 @@ export default function ChatView(props: ChatViewProps) { ), title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`, description: "Finishing an update", + actions: disconnectAction, }); } else { items.push({ @@ -2488,28 +2561,22 @@ export default function ChatView(props: ChatViewProps) { variant: unavailableConnection.phase === "error" ? "error" : "warning", icon: , title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, - description: environmentReconnecting ? "Trying again" : "Reconnect to continue", actions: ( <> - - + {!environmentReconnecting ? ( + + ) : null} + {disconnectAction} ), }); @@ -2564,22 +2631,22 @@ export default function ChatView(props: ChatViewProps) { (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) ? serverUpdateGuidance(versionMismatchSelfUpdate) : undefined, - actions: - updateInProgress || - !versionMismatch || + actions: updateInProgress ? ( + disconnectAction + ) : !versionMismatch || (versionMismatchSelfUpdate === "desktop-managed" && !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), + + ), ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) ? {} : { @@ -2603,7 +2670,9 @@ export default function ChatView(props: ChatViewProps) { activeEnvironmentUnavailableState, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, - navigate, + canDisconnectActiveEnvironment, + disconnectingEnvironment, + handleDisconnectActiveEnvironment, setDismissedVersionMismatchKey, showVersionMismatchBanner, serverUpdateFailureDismissed, @@ -3290,6 +3359,64 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, ); const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); + // Live stages of a bootstrap worktree setup. The subscription follows the + // thread that was set up, not the route: a deleted bootstrap thread rotates + // the draft's thread id, and the failed card must survive that. + const worktreeSetupOwnerKey = draftId ?? routeThreadKey; + const worktreeSetupActive = + worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; + // The setup runs on the environment that received the dispatch, so both + // the subscription and cancel target that one even if the draft's machine + // picker changes underneath. + const worktreeSetupQuery = useEnvironmentQuery( + worktreeSetupActive + ? vcsEnvironment.worktreeSetup({ + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetupRef.threadId }, + }) + : null, + ); + const latestWorktreeSetup = worktreeSetupQuery.data; + useEffect(() => { + // The server drops finished snapshots after a grace period and emits null. + // Hold the last real snapshot so a settled card does not vanish. + if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup); + }, [latestWorktreeSetup]); + const worktreeSetup = + worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId + ? heldWorktreeSetup + : null; + // A finished card is dropped once the agent's turn shows in the timeline: + // the card belongs to the send, and the agent takes over from there. + const worktreeSetupDoneAndTurnVisible = + worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null; + useEffect(() => { + if (!worktreeSetupDoneAndTurnVisible) return; + setWorktreeSetupRef(null); + setHeldWorktreeSetup(null); + }, [worktreeSetupDoneAndTurnVisible]); + const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { + reportFailure: false, + }); + const onCancelWorktreeSetup = useCallback(() => { + if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return; + void cancelWorktreeSetup({ + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }); + }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]); + // The setup terminal belongs to the thread that was set up. A failed + // bootstrap deletes that thread and closes its terminals, so only offer the + // terminal while the setup thread is still the active one. + const onOpenWorktreeSetupTerminal = useMemo(() => { + if (!worktreeSetup || !activeThreadRef || worktreeSetup.threadId !== activeThreadRef.threadId) { + return null; + } + const setupThreadRef = activeThreadRef; + return (terminalId: string) => { + storeEnsureTerminal(setupThreadRef, terminalId, { open: true, active: true }); + }; + }, [activeThreadRef, storeEnsureTerminal, worktreeSetup]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3299,6 +3426,9 @@ export default function ChatView(props: ChatViewProps) { isWorking, draftHeroDockRequested, backgroundSubmissionPending, + // A cancelled or failed setup card stays on the draft's timeline; the + // hero headline would paint over it. + hasWorktreeSetupCard: worktreeSetup !== null, }); const [ attachDraftHeroTransitionGroupRef, @@ -7232,6 +7362,11 @@ export default function ChatView(props: ChatViewProps) { preparingWorktree: Boolean(baseBranchForWorktree), submissionIntent: resolvedSubmissionIntent, }); + setWorktreeSetupRef( + baseBranchForWorktree + ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } + : null, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -8318,6 +8453,70 @@ export default function ChatView(props: ChatViewProps) { ], ); + // "Work locally" on the setup card: cancel the bootstrap and remember the + // draft. The cancelled dispatch deletes the half-made thread and puts the + // message back in the composer; the effect below then flips the draft to + // local mode and resends. The draft is a server thread for the whole + // setup (the bootstrap created it), so this keys off the route, not + // `isLocalDraftThread`. + const onWorktreeSetupWorkLocally = useCallback(() => { + if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running" || !draftId) { + return; + } + const target = { + environmentId: worktreeSetupRef.environmentId, + input: { threadId: worktreeSetup.threadId }, + }; + void (async () => { + const result = await cancelWorktreeSetup(target); + if (result._tag !== "Success" || !result.value.cancelled) return; + setWorkLocallyResendDraftId(draftId); + })(); + }, [cancelWorktreeSetup, draftId, worktreeSetup, worktreeSetupRef]); + const onSendRef = useRef(onSend); + onSendRef.current = onSend; + // Resend once the cancelled dispatch has settled and the composer is free. + // Every state that makes `onSend` bail and wait is part of the readiness + // check, so the flag survives a reconnect, a reverting checkpoint, or a + // feedback upload in between. What remains inside `onSend` are the checks + // that need the user to change something, and those should not auto retry. + const workLocallyResendReady = + workLocallyResendDraftId !== null && + workLocallyResendDraftId === draftId && + isLocalDraftThread && + !isSendBusy && + !isConnecting && + !isRevertingCheckpoint && + !threadDetailLoading && + clientSettingsHydrated && + !needsLoadBalancing && + !activeEnvironmentUnavailable && + !activePendingProgress && + !feedbackUploading; + useEffect(() => { + if ( + !workLocallyResendReady || + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) + ) { + return; + } + if (sendEnvMode !== "local") { + // The draft is back; switch it to the project checkout and let the next + // render resend. + setDraftThreadContext(composerDraftTarget, { envMode: "local", startFromOrigin: false }); + return; + } + setWorkLocallyResendDraftId(null); + void onSendRef.current(); + }, [ + composerDraftTarget, + routeThreadKey, + sendEnvMode, + setDraftThreadContext, + workLocallyResendReady, + ]); + const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { setPendingServerThreadStartFromOriginByThreadId((current) => @@ -8761,6 +8960,10 @@ export default function ChatView(props: ChatViewProps) { isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} isCompacting={!paintOnlyDisplayedTimeline && isCompacting} activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} + worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} + onCancelWorktreeSetup={onCancelWorktreeSetup} + {...(draftId ? { onWorktreeSetupWorkLocally } : {})} + {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} listRef={legendListRef} timelineEntries={displayedTimeline.entries} latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8af4419fca31..f33a9cce63b0 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -953,8 +953,13 @@ function OpenCommandPaletteDialog(props: { ) : ""; const browsePath = useMemo( - () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), - [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + () => + getFilesystemBrowsePath( + query, + browseEnvironmentPlatform, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + ), + [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1553,6 +1558,14 @@ function OpenCommandPaletteDialog(props: { ); const openAddProjectFlow = useCallback(() => { + // With no environment at all there is nothing to browse, so the only + // useful next step is connecting one. + if (addProjectEnvironmentOptions.length === 0) { + setOpen(false); + void navigate({ to: "/settings/connections" }); + return; + } + if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { pushPaletteView({ addonIcon: , @@ -1561,24 +1574,14 @@ function OpenCommandPaletteDialog(props: { 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); + void startAddProjectSourceSelection(defaultAddProjectEnvironmentId); }, [ addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + navigate, pushPaletteView, + setOpen, startAddProjectSourceSelection, ]); @@ -1775,7 +1778,6 @@ function OpenCommandPaletteDialog(props: { "environment", ], title: "Add project", - disabled: defaultAddProjectEnvironmentId === null, icon: , keepOpen: true, run: async () => { diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 37398f266081..81fd6047f13e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -199,11 +199,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; -import { - useClientSettings, - useCompactSidebarEnabled, - useUpdateClientSettings, -} from "~/hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, @@ -1198,9 +1194,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const router = useRouter(); const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); - const { isMobile, setOpenMobile, state, setOpen } = useSidebar(); - const compactSidebarEnabled = useCompactSidebarEnabled(); - const isCompact = compactSidebarEnabled && !isMobile && state === "collapsed"; + const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); @@ -1451,13 +1445,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); - if (isCompact) setOpen(true); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, [ clearSelection, - isCompact, - setOpen, dragInProgressRef, projectExpanded, projectPreferenceKeys, @@ -1474,17 +1465,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); - if (isCompact) setOpen(true); + setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, - [ - dragInProgressRef, - isCompact, - projectExpanded, - projectPreferenceKeys, - setOpen, - setProjectExpanded, - ], + [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -2376,10 +2359,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec <>
- {isCompact ? null : !projectExpanded && projectStatus ? ( + {!projectExpanded && projectStatus ? ( - + {project.displayName} @@ -2434,7 +2415,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {/* Environment badge – visible by default, crossfades with the "new thread" button on hover using the same pointer-events + opacity pattern as the thread row archive/timestamp swap. */} - {!isCompact && project.environmentPresence === "remote-only" && ( + {project.environmentPresence === "remote-only" && ( +
); } @@ -754,9 +705,6 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -795,34 +743,6 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { }, [draftId, onDiscard], ); - if (compact) { - return ( -
  • - - - } - > - - - -
    {props.projectDisplayName}
    -
    {preview}
    -
    -
    -
  • - ); - } return (
  • = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; - compact: boolean; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). variantAction: "settle" | "unsettle" | "unsnooze"; @@ -1113,9 +1032,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variant, variantAction, } = props; - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -1293,16 +1209,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { branchMismatch={branchMismatch} terminalStatus={terminalStatus} terminalProcessCount={terminalProcessCount} - compactStatus={ - compact || (props.compact && variant === "card") - ? (topStatus?.label ?? - (variantAction === "unsnooze" - ? "Snoozed" - : variantAction === "unsettle" - ? "Settled" - : "Ready")) - : undefined - } /> ); @@ -1349,7 +1255,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [isRenaming, onStartRename, thread.title, threadRef], ); const [isFileDragOver, setIsFileDragOver] = useState(false); - const [tooltipOpen, setTooltipOpen] = useState(false); const fileDropHandlers = useMemo( () => onFileDropThreads @@ -1512,7 +1417,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // A zero-height boundary also makes dnd-kit scale the source to // zero. Only projected peers use scaleY as a visibility sentinel. visibility: - sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0) + !sortable.isDragging && sortable.transform?.scaleY === 0 ? ("hidden" as const) : undefined, }, @@ -1584,21 +1489,18 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); - const renderPrBadge = (iconOnly: boolean, variant: "underline" | "badge" = "underline") => + const prBadge = prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( ) : null; - const hasPrBadge = prBadgeShape?.kind === "stack" || pr !== null || currentLinkedPr !== null; - const prBadge = renderPrBadge(false); const terminalStatusIcon = terminalStatus ? ( - - - } - > - - {props.project ? ( - - ) : driverKind ? ( - - ) : ( - - )} - {isRemote ? ( - - - } - > - - - - {props.environmentLabel ?? "Remote environment"} - - - ) : null} - {hasPrBadge ? ( - - {renderPrBadge(true, "badge")} - - ) : null} - - {topStatus ? ( - - ) : hasUnsentDraft ? ( - - ) : null} - {props.jumpLabel ? : null} - - {sortable?.isDragging ? ( - {dragDestination} - ) : ( - detailsTooltip - )} - -
  • - ); - } - if (variant === "slim") { return (
  • - + - + } > -
    -
    +
    +
    {draftIndicator} {props.project ? ( - - - {compactRows && isRemote ? ( - - - } - > - - - - {props.environmentLabel ?? "Remote environment"} - - - ) : null} - - ) : compactRows && isRemote ? ( - - - } - > - - - - {props.environmentLabel ?? "Remote environment"} - - + ) : null} - {compactRows ? ( - title - ) : props.projectDisplayName ? ( + {props.projectDisplayName ? ( )} {pinIndicator} - {compactRows ? ( - <> - {terminalStatusIcon} - {topStatus && CompactStatusIcon ? ( - isWokeStatus ? ( - - ) : ( - - - {topStatus.label} - - ) - ) : null} - {renderPrBadge(true)} - - ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -2067,35 +1766,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {sortable?.isDragging ? ( dragDestination ) : ( - + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} - {compactRows ? ( - status === "working" ? ( - - - - ) : compactCompletedAt ? ( - - ) : ( - threadTimeLabel(thread) - ) - ) : topStatus ? ( + {topStatus ? ( isWokeStatus ? ( - {compactRows ? null : "Settle"} + Settle Settle thread @@ -2211,70 +1895,68 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )}
    - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {compactRows ? null : ( - <> -
    {title}
    -
    - {/* Always the branch. The plan step used to take this slot while +
    + {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    +
    + {/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - - {thread.branch} - - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - - - ) : null} + {thread.branch ? ( + <> + + + {thread.branch} -
    - - )} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} + +
    {props.jumpLabel ? : null} @@ -2308,9 +1990,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -2396,7 +2075,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onClick={props.onSelect} className={cn( "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", - compact && "justify-center px-0", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2408,15 +2086,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { > {props.project ? ( - ) : compact ? ( - ) : null} - {thread.title} - + {thread.title} + {threadTimeLabel(thread)} @@ -2444,12 +2116,8 @@ export default function Sidebar() { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); - const { isMobile, setOpenMobile, setOpen, state: sidebarState } = useSidebar(); - const compactEnabled = useCompactSidebarEnabled(); - const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; - const [snoozedFooter, setSnoozedFooter] = useState(null); + const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -3020,7 +2688,6 @@ export default function Sidebar() { [setSettledShelfExpanded], ); const renderedSettledThreads = useMemo(() => { - if (compact) return EMPTY_THREADS; if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = visibleSettledThreads.find( @@ -3028,7 +2695,7 @@ export default function Sidebar() { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? EMPTY_THREADS : [routeThread]; - }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -3256,14 +2923,10 @@ export default function Sidebar() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const startThreadRename = useCallback( - (threadRef: ScopedThreadRef, title: string) => { - if (compact) setOpen(true); - setRenamingThreadKey(scopedThreadKey(threadRef)); - setRenamingTitle(title); - }, - [compact, setOpen], - ); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { @@ -3425,18 +3088,8 @@ export default function Sidebar() { const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); const restrictBelowPins = useCallback( - (args) => - restrictBelowSidebarLabel( - { - ...args, - // The fixed snoozed shelf shares the main list's drag boundary. - containerNodeRect: compact - ? (threadListRef.current?.getBoundingClientRect() ?? args.containerNodeRect) - : args.containerNodeRect, - }, - dragLabelOffsetRef.current, - ), - [compact], + (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), + [], ); const listMotionRef = useRef | null>(null); const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { @@ -3658,7 +3311,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - (compact ? 0 : settledThreads.length) === + settledThreads.length === 0 ) { return []; @@ -3674,15 +3327,13 @@ export default function Sidebar() { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); } - if (!compact) { - items.push({ kind: "marker", marker: "settled-header" }); - items.push({ kind: "marker", marker: "settled-placeholder" }); - items.push(...rowsOf(renderedSettledThreads, "settled")); - } + items.push({ kind: "marker", marker: "settled-header" }); + const settledRows = rowsOf(renderedSettledThreads, "settled"); + items.push({ kind: "marker", marker: "settled-placeholder" }); + items.push(...settledRows); return items; }, [ activeThreads, - compact, pinnedThreads, renderedSettledThreads, settledThreads.length, @@ -3752,7 +3403,6 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, - compact, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3761,7 +3411,6 @@ export default function Sidebar() { snoozedThreadCount: snoozedThreads.length, }), [ - compact, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3770,22 +3419,6 @@ export default function Sidebar() { snoozedThreads.length, ], ); - const draggingCompactSnoozed = compact && dragState?.activeSection === "snoozed"; - const compactSnoozedDragThread = - draggingCompactSnoozed && dragState ? threadByKey.get(dragState.activeKey) : undefined; - const compactSidebarSortingStrategy = useCallback( - (args) => { - const item = sidebarListItems[args.index]; - // Footer rows stay anchored while the main list previews a reorder. - if ( - item?.kind === "thread" ? item.section === "snoozed" : item?.marker === "snoozed-header" - ) { - return null; - } - return sidebarSortingStrategy(args); - }, - [sidebarListItems, sidebarSortingStrategy], - ); // Hidden and filtered threads keep their keys. Reserve those slots without // including the rows in the visible drop order or writing to them. const { pinnedKeysById, activeKeysById } = useMemo( @@ -4689,19 +4322,7 @@ export default function Sidebar() { <> 0 ? ( -
    ))} {editing ? ( -
    { - event.preventDefault(); - void save(editing, false, originalHost ?? editing); - }} - > - - - - -
    - - - -
    - {checks[editing.id]?.pending ? ( - - - Checking connection… - - ) : null} - {checks[editing.id]?.platforms ? ( - - ) : null} - {checks[editing.id]?.error ? ( -

    - {checks[editing.id]?.error} -

    - ) : null} - + void save(host, false, originalHost ?? host)} + onClose={() => setEditing(null)} + /> ) : null} )} @@ -247,49 +156,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null ); } -function useHostConnectionChecks(environmentId: EnvironmentId | null) { - const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); - const [checks, setChecks] = useState< - Record< - string, - { pending?: boolean; platforms?: ReadonlyArray; error?: string } - > - >({}); - const setCheck = (id: string, value: (typeof checks)[string]) => - setChecks((current) => ({ ...current, [id]: value })); - const testConnection = async (host: SshDeviceHostConfig) => { - if (!environmentId || checks[host.id]?.pending) return; - setCheck(host.id, { pending: true }); - try { - const summary = await test({ environmentId: environmentId, input: host }); - setCheck( - host.id, - summary._tag === "Failure" - ? { error: Cause.pretty(summary.cause) } - : { platforms: summary.value.platforms }, - ); - } catch (error) { - setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); - } - }; - return { checks, testConnection }; -} - function DeviceHostList({ environmentId, hosts, busy, onEdit, onRemove, + checks, + testConnection, }: { environmentId: EnvironmentId; hosts: ReadonlyArray; busy: boolean; onEdit: (host: SshDeviceHostConfig) => void; onRemove: (host: SshDeviceHostConfig) => void; + checks: ReturnType["checks"]; + testConnection: ReturnType["testConnection"]; }) { const { state } = useDeviceState(environmentId); - const { checks, testConnection } = useHostConnectionChecks(environmentId); return ( <> {hosts.length === 0 ? ( @@ -297,17 +181,27 @@ function DeviceHostList({ ) : null} {hosts.map((host) => { const status = state.hostStatuses[host.id]; - const check = checks[host.id]; + const check = checks[deviceHostConnectionKey(host)]?.[environmentId]; const platforms = - check?.platforms ?? state.hosts.find((value) => value.id === host.id)?.platforms ?? []; - const progress = check?.pending - ? "Checking connection…" - : status?.status === "installing" - ? "Installing device support…" - : status?.status === "starting" - ? "Connecting…" - : null; - const error = check?.error ?? (status?.status === "failed" ? status.detail : undefined); + (check?.status === "connected" ? check.platforms : undefined) ?? + state.hosts.find((value) => value.id === host.id)?.platforms ?? + []; + const progress = + check?.status === "pending" + ? "Checking connection…" + : status?.status === "installing" + ? "Installing device support…" + : status?.status === "starting" + ? "Connecting…" + : null; + const error = + check?.status === "failed" + ? check.error + : check?.status === "local" + ? undefined + : status?.status === "failed" + ? status.detail + : undefined; return (
    @@ -342,6 +236,9 @@ function DeviceHostList({ ))}

    {host.target}

    + {check?.status === "local" ? ( +

    Already available locally

    + ) : null} {error ? (
    diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx index f990aa5a2441..b3b33fab0252 100644 --- a/apps/web/src/components/settings/EnvironmentIconPicker.tsx +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -5,7 +5,6 @@ import { type EnvironmentId, type ServerConfig, } from "@t3tools/contracts"; -import { useCallback } from "react"; import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; @@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useEnvironmentSessionState } from "../../state/session"; import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + MenuItem, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, +} from "../ui/menu"; import { resolvePrimaryOperateAccess, resolveRemoteOperateAccess, } from "./ProviderSettingsPanel.logic"; -const AUTOMATIC_VALUE = "automatic"; - /** * Why the picker is inert, in the order the user can do something about it. * Null means it can be changed. @@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) { } /** - * Picks the machine glyph an environment wears everywhere it is listed. - * "Automatic" clears the override so the server's own detection shows - * through; the label says what that currently resolves to so the user can - * tell whether detection got it right before overriding. The control stays - * visible while locked so the current icon still reads, the same way - * server-scoped rows go inert instead of disappearing. + * "Icon" submenu for an environment's row menu. Lists the machine kinds with + * the server's own detection marked, so the user can tell whether detection + * got it right before overriding. Picking the detected kind clears the + * override. Locked environments show the reason as a disabled item instead of + * hiding the submenu, so the current icon still reads. */ -export function EnvironmentIconPicker({ +export function EnvironmentIconMenu({ environmentId, serverConfig, - size = "sm", }: { readonly environmentId: EnvironmentId; readonly serverConfig: ServerConfig | null; - readonly size?: "xs" | "sm"; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const operateAccess = useEnvironmentOperateAccess(environmentId); const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); - const override = serverConfig?.settings.environmentIcon ?? null; - const detected = serverConfig?.environment.platform.machine ?? null; + // With no detection the server falls back to "server", so picking that + // kind clears the override the same way picking the detected kind does. + const detected = serverConfig?.environment.platform.machine ?? "server"; const resolved = resolveEnvironmentMachineKind(serverConfig); - const value = override ?? AUTOMATIC_VALUE; - const automaticLabel = - detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`; - - const handleValueChange = useCallback( - (next: string | null) => { - if (next === null) return; - if (next === AUTOMATIC_VALUE) { - updateSettings({ environmentIcon: null }); - } else if (isEnvironmentMachineKind(next)) { - updateSettings({ environmentIcon: next }); - } - }, - [updateSettings], - ); - const select = ( - - ); - - if (lock === null) { - return select; - } return ( - - - } - > - {select} - - - {lock} - - + + + + Icon + + + {lock !== null ? ( + <> + + {lock} + + + + ) : null} + { + if (lock !== null || !isEnvironmentMachineKind(next)) return; + updateSettings({ environmentIcon: next === detected ? null : next }); + }} + > + {ENVIRONMENT_MACHINE_KINDS.map((kind) => ( + + + + + {ENVIRONMENT_MACHINE_KIND_LABELS[kind]} + + {kind === detected ? ( + + {serverConfig?.environment.platform.machine ? "detected" : "default"} + + ) : null} + + + ))} + + + ); } diff --git a/apps/web/src/components/settings/EnvironmentRow.tsx b/apps/web/src/components/settings/EnvironmentRow.tsx new file mode 100644 index 000000000000..5f3dae7e47c1 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentRow.tsx @@ -0,0 +1,74 @@ +import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import type { EnvironmentPresentation } from "~/state/environments"; +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; + +export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { + const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; + return target.port ? `${authority}:${target.port}` : authority; +} + +/** + * How this client reaches a machine, printed first in every environment row so + * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend. + */ +export function environmentTransportLabel(environment: EnvironmentPresentation): string { + const { entry } = environment; + if (entry.target._tag === "PrimaryConnectionTarget") return "This machine"; + if (environment.relayManaged) return "T3 Connect"; + if (isDesktopLocalConnectionTarget(entry.target)) return "WSL"; + if ( + entry.target._tag === "SshConnectionTarget" && + Option.isSome(entry.profile) && + entry.profile.value._tag === "SshConnectionProfile" + ) { + return `SSH ${formatDesktopSshTarget(entry.profile.value.target)}`; + } + return environment.displayUrl ?? "Remote link"; +} + +/** + * One machine in a grouped settings list: icon, name, a single subtitle line, + * and controls on the right. Every environment list on the Connections page + * uses this so the lists share one rhythm. + */ +export function EnvironmentRow({ + kind, + label, + subtitle, + below, + dimmed = false, + className, + children, +}: { + readonly kind: EnvironmentMachineKind; + readonly label: string; + readonly subtitle: ReactNode; + /** Extra content under the subtitle, such as update progress. */ + readonly below?: ReactNode; + readonly dimmed?: boolean; + readonly className?: string; + readonly children?: ReactNode; +}) { + return ( +
    + +
    +

    {label}

    +
    {subtitle}
    + {below} +
    +
    {children}
    +
    + ); +} diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx new file mode 100644 index 000000000000..109e97b9d8e8 --- /dev/null +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -0,0 +1,67 @@ +import { ChevronRightIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { useSettingsSearchTarget, useSettingsSearchTargetId } from "./settingsLayout"; + +/** + * A grouped settings section that starts closed. The header carries the title, + * a one line summary of what is set inside, and an optional control such as + * the section's own switch. A settings search that targets the section opens it. + */ +export function FoldedSettingsSection({ + id, + title, + summary, + control, + children, +}: { + readonly id: string; + readonly title: string; + readonly summary?: string | null; + readonly control?: ReactNode; + readonly children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); + const targetRef = useSettingsSearchTarget(id); + // A search jump lands inside the fold, so open it before the scroll runs. + const [openedForTarget, setOpenedForTarget] = useState(null); + if (searchTargetId === id && openedForTarget !== id) { + setOpenedForTarget(id); + if (!open) setOpen(true); + } + + return ( +
    + +
    + + + {title} + {summary ? ( + {summary} + ) : null} + + {control ?
    {control}
    : null} +
    + +
    + {children} +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.test.ts b/apps/web/src/components/settings/GitHubRoutingSettings.test.ts new file mode 100644 index 000000000000..5f09ab69c99d --- /dev/null +++ b/apps/web/src/components/settings/GitHubRoutingSettings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { summarizeGitHubRouting } from "./GitHubRoutingSettings"; + +describe("summarizeGitHubRouting", () => { + it("is empty when no machine shares", () => { + expect(summarizeGitHubRouting([{ label: "alvin", permission: "off" }])).toBeNull(); + }); + + it("groups sharing machines by permission, read and act first", () => { + expect( + summarizeGitHubRouting([ + { label: "alvin", permission: "read" }, + { label: "bb-1", permission: "read-write" }, + { label: "cup2", permission: "off" }, + { label: "Theo's MacBook Pro", permission: "read-write" }, + ]), + ).toBe("bb-1, Theo's MacBook Pro read and act · alvin read PRs"); + }); +}); diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.tsx b/apps/web/src/components/settings/GitHubRoutingSettings.tsx index fad58b4f644b..10bb2dd98e35 100644 --- a/apps/web/src/components/settings/GitHubRoutingSettings.tsx +++ b/apps/web/src/components/settings/GitHubRoutingSettings.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { gitHubRoutingConnectionKey, gitHubRoutingPermissionFor, @@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { toastManager } from "../ui/toast"; -import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { searchableSetting } from "./settingsSearch"; const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [ @@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> { value: "read-write", label: "Read and act" }, ]; +const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const; + +/** + * Closed-header summary: the machines that share, grouped by permission. + * Null when nothing is shared. + */ +export function summarizeGitHubRouting( + entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>, +): string | null { + const groups = (["read-write", "read"] as const).flatMap((permission) => { + const labels = entries.filter((entry) => entry.permission === permission); + return labels.length === 0 + ? [] + : [`${labels.map((entry) => entry.label).join(", ")} ${summaryLabels[permission]}`]; + }); + return groups.length === 0 ? null : groups.join(" · "); +} + +/** + * Folded section under the environments list. One row per switched-on machine + * with how much of its GitHub access the other machines may use. The trust + * warning is the first line of the body so it sits next to the control. + * Rendered only when two or more machines are on. + */ export function GitHubRoutingSettings({ environments, - selectedEnvironmentId, }: { readonly environments: ReadonlyArray; - readonly selectedEnvironmentId: EnvironmentId; }) { const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); const [saving, setSaving] = useState(false); - const selectedEnvironments = environments.filter( - (environment) => environment.environmentId === selectedEnvironmentId, - ); + if (environments.length < 2) return null; + + const { id, title } = searchableSetting("github-routing"); return ( - - - {selectedEnvironments.map((environment) => ( - ({ + label: environment.label, + permission: gitHubRoutingPermissionFor(environment.entry, permissions), + })), + ) ?? "Off" + } + > +

    + Machines you trust here can read PR data through each other's GitHub access. Enable both + machines. Read and act may use broader permissions than the machine that owns them. This + applies only to this device. +

    + {environments.map((environment) => ( + { - if (permission === null) return; - setSaving(true); - void update({ environmentId: environment.environmentId, permission }).then( - (result) => { - setSaving(false); - if (result._tag === "Failure") - toastManager.add({ - type: "error", - title: "Could not save GitHub routing permission", - }); - }, - ); - }} + kind={resolveEnvironmentMachineKind(environment.serverConfig)} + label={environment.label} + subtitle={environmentTransportLabel(environment)} + > + - } - /> + + + + {options.map(({ value, label }) => ( + + {label} + + ))} + + + ))} -
    + ); } diff --git a/apps/web/src/components/settings/LoadBalancingSettings.test.ts b/apps/web/src/components/settings/LoadBalancingSettings.test.ts new file mode 100644 index 000000000000..53e3c16d6f8d --- /dev/null +++ b/apps/web/src/components/settings/LoadBalancingSettings.test.ts @@ -0,0 +1,32 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { loadPreferenceForWeight, summarizeLoadPreferences } from "./LoadBalancingSettings"; + +const machines = [ + { environmentId: EnvironmentId.make("a"), label: "alvin" }, + { environmentId: EnvironmentId.make("b"), label: "bb-1" }, + { environmentId: EnvironmentId.make("c"), label: "ProMini" }, +]; + +describe("loadPreferenceForWeight", () => { + it("snaps legacy slider weights onto the four preferences", () => { + expect(loadPreferenceForWeight(undefined)).toBe(50); + expect(loadPreferenceForWeight(0)).toBe(0); + expect(loadPreferenceForWeight(10)).toBe(25); + expect(loadPreferenceForWeight(50)).toBe(50); + expect(loadPreferenceForWeight(80)).toBe(100); + }); +}); + +describe("summarizeLoadPreferences", () => { + it("is empty when every machine is at Normal", () => { + expect(summarizeLoadPreferences(machines, { a: 50 })).toBeNull(); + }); + + it("lists only the machines that differ from Normal, in list order", () => { + expect(summarizeLoadPreferences(machines, { b: 100, c: 0 })).toBe( + "bb-1 prefer · ProMini manual only", + ); + }); +}); diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx index ae42139f58de..1784e3732105 100644 --- a/apps/web/src/components/settings/LoadBalancingSettings.tsx +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -1,3 +1,5 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; + import { useClientSettings, useClientSettingsHydrated, @@ -6,7 +8,8 @@ import { import type { EnvironmentPresentation } from "~/state/environments"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; -import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { searchableSetting } from "./settingsSearch"; const preferences = [ @@ -14,8 +17,44 @@ const preferences = [ { value: 50, label: "Normal" }, { value: 25, label: "Less often" }, { value: 0, label: "Manual only" }, -]; +] as const; + +type LoadPreference = (typeof preferences)[number]["value"]; + +/** Snaps a saved weight (older builds stored a slider value) onto the four preferences. */ +export function loadPreferenceForWeight(weight: number | undefined): LoadPreference { + if (weight === undefined || weight === 50) return 50; + if (weight === 0) return 0; + return weight < 50 ? 25 : 100; +} + +function preferenceLabel(preference: LoadPreference): string { + return preferences.find((entry) => entry.value === preference)!.label; +} +/** + * Closed-header summary: the machines not at Normal, so the folded section + * still tells you what is set. Null when every machine is at the default. + */ +export function summarizeLoadPreferences( + environments: ReadonlyArray>, + weights: Readonly>, +): string | null { + const parts = environments.flatMap((environment) => { + const preference = loadPreferenceForWeight(weights[environment.environmentId]); + return preference === 50 + ? [] + : [`${environment.label} ${preferenceLabel(preference).toLowerCase()}`]; + }); + return parts.length === 0 ? null : parts.join(" · "); +} + +/** + * Folded section under the environments list. Its switch turns balancing on + * for this client, and the body holds one row per switched-on machine with + * how often that machine should receive new threads. Rendered only when two + * or more machines are on, since one machine has nothing to balance against. + */ export function LoadBalancingSettings({ environments, }: { @@ -25,78 +64,71 @@ export function LoadBalancingSettings({ const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdateClientSettings(); - if (environments.length < 2) { - return ( - -

    - Connect another machine to automatically balance load across environments. -

    -
    - ); - } + if (environments.length < 2) return null; + const { id, title } = searchableSetting("load-balancing"); return ( - - updateSettings({ loadBalancingEnabled })} - /> - } - /> - - ); -} - -export function LoadBalancingPreference({ environment }: { environment: EnvironmentPresentation }) { - const settings = useClientSettings(); - const settingsHydrated = useClientSettingsHydrated(); - const updateSettings = useUpdateClientSettings(); - const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; - // Keep saved slider weights until the user chooses a different preference. - const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; - - return ( - { - if (value !== null) { + updateSettings({ loadBalancingEnabled })} + /> + } + > +

    + New threads in shared projects start on the machine with the most free CPU and memory, + weighted by each machine's preference. +

    + {environments.map((environment) => ( + + - } - /> + + + + + {preferences.map(({ value, label }) => ( + + {label} + + ))} + + + + ))} + ); } diff --git a/apps/web/src/components/settings/LocalEnvironmentSetting.tsx b/apps/web/src/components/settings/LocalEnvironmentSetting.tsx new file mode 100644 index 000000000000..d32e28030074 --- /dev/null +++ b/apps/web/src/components/settings/LocalEnvironmentSetting.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; + +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +// Toggling relaunches the desktop app, so the switch only reflects the value +// this process started with; there is no live state to keep in sync. +export function LocalEnvironmentSetting() { + const setEnabled = window.desktopBridge?.setLocalEnvironmentEnabled; + const [enabled] = useState(() => !isLocalEnvironmentDisabled()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + if (!setEnabled) return null; + + const applyChange = async () => { + setIsUpdating(true); + setError(null); + try { + await setEnabled(!enabled); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn't change this setting."); + setIsUpdating(false); + } + }; + + return ( + <> + setConfirmOpen(true)} + aria-label="Local environment" + /> + } + /> + { + if (isUpdating) return; + setConfirmOpen(open); + if (!open) setError(null); + }} + > + + + + {enabled ? "Turn off local environment?" : "Turn on local environment?"} + + + {enabled + ? "T3 Code will restart without running a server on this computer. Any agents and terminals running here will stop, and other devices will no longer be able to connect to this computer. Your projects, history, and remote environments are unaffected." + : "T3 Code will restart and start running a server on this computer again."} + + + {error ?

    {error}

    : null} + + }> + Cancel + + + +
    +
    + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 67a20b31ecd2..6baa561a91fe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -38,6 +38,7 @@ import { MIN_PANEL_ANIMATION_DURATION_MS, MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + type ResponseStreamingMode, MIN_TERMINAL_FONT_SIZE, type QuitConfirmationMode, } from "@t3tools/contracts/settings"; @@ -94,6 +95,15 @@ import { isMacPlatform } from "../../lib/utils"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { @@ -160,7 +170,6 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; -import { CompactSidebarPreview } from "./CompactSidebarPreview"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -168,6 +177,18 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { + turn: "Wait for the full response", + paragraph: "Show finished paragraphs", + token: "Token by token (legacy)", +}; + +const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record = { + turn: "Text appears once the agent finishes its turn.", + paragraph: "Each paragraph or code block appears as soon as it is complete.", + token: "Every token repaints the message as it arrives. Slower and harder to read.", +}; + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -502,10 +523,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(theme !== "system" ? ["Theme"] : []), ...(!followSystem ? ["Follow system"] : []), ...(themeHalves !== null ? ["Theme mix"] : []), - ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled || - settings.sidebarCompactThreadRows !== DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows - ? ["Compact sidebar"] - : []), ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? ["Contrast"] : []), @@ -564,9 +581,8 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), - ...(settings.enableLegacyTokenStreaming !== - DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming - ? ["Stream token by token"] + ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode + ? ["Response streaming"] : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks @@ -613,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, - settings.compactSidebarEnabled, settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, @@ -640,12 +655,11 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizeTerminal, settings.glassOpacity, settings.panelAnimationDurationMs, - settings.enableLegacyTokenStreaming, + settings.responseStreamingMode, settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, - settings.sidebarCompactThreadRows, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, @@ -723,7 +737,6 @@ export function useSettingsRestore(onRestored?: () => void) { } updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, - compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, @@ -741,10 +754,9 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, + responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -797,6 +809,44 @@ export function useSettingsRestore(onRestored?: () => void) { }; } +/** + * Gate in front of the legacy token-by-token mode. The primary action steers + * the user to paragraph streaming; the legacy path is the quiet option. + */ +function TokenStreamingWarningDialog({ + open, + onOpenChange, + onConfirm, + onUseParagraphs, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + onUseParagraphs: () => void; +}) { + return ( + + + + Token by token is a worse experience + + Token streaming repaints the message on every delta. It is slower, harder to read, and + costs more CPU on every connected device. This mode stays only for backwards + compatibility. Use paragraph streaming instead. + + + + + }>Cancel + + + + + ); +} + function BackgroundActivityAdvancedDialog({ open, onOpenChange, @@ -1084,19 +1134,6 @@ export function AppearanceSettingsPanel() { const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); - const compactSidebarMode = settings.compactSidebarEnabled - ? settings.sidebarCompactThreadRows - ? "both" - : "rail" - : settings.sidebarCompactThreadRows - ? "threads" - : "off"; - const compactSidebarModes = { - off: "Off", - rail: "Rail only", - threads: "Threads only", - both: "Both", - }; const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1383,64 +1420,6 @@ export function AppearanceSettingsPanel() { /> - - - updateSettings({ - compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled, - sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows, - }) - } - /> - ) : null - } - control={ -
    - - -
    - } - /> -
    - ); @@ -2014,7 +1993,6 @@ function AutoSettleDaysInput({ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ "legacy-plan-mode", "legacy-context-window-indicator", - "legacy-token-streaming", "legacy-sidebar", ]); @@ -2082,35 +2060,6 @@ function LegacyFeaturesSection() { /> } /> - { - if (!checked) { - updateSettings({ enableLegacyTokenStreaming: false }); - return; - } - void (async () => { - const api = readLocalApi(); - const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( - [ - "Turn on token-by-token output?", - "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", - ].join("\n"), - ); - if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); - })(); - }} - aria-label="Stream token by token (legacy)" - /> - } - /> 0; const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); + const [tokenStreamingWarningOpen, setTokenStreamingWarningOpen] = useState(false); + const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); @@ -2387,6 +2338,76 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, + }) + } + /> + ) : null + } + control={ + <> + + { + updateSettings({ responseStreamingMode: "token" }); + setTokenStreamingWarningOpen(false); + }} + onUseParagraphs={() => { + updateSettings({ responseStreamingMode: "paragraph" }); + setTokenStreamingWarningOpen(false); + }} + /> + + } + /> item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), ); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); - const compactSidebarEnabled = useCompactSidebarEnabled(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); - const isSearching = query.trim().length > 0 && !(compactSidebarEnabled && !isMobile && !open); + const isSearching = query.trim().length > 0; const hasResults = results.length > 0; useEffect(() => { @@ -235,18 +233,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { <> - { - setOpen(true); - requestAnimationFrame(() => searchInputRef.current?.focus()); - }} - > - - -
    +
    handleSectionClick(item.to)} > - - {item.label} - + {item.label} ); @@ -360,12 +343,10 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { -
    - - - -
    -
    + + + +
    diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts new file mode 100644 index 000000000000..f9b003812bfe --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts @@ -0,0 +1,85 @@ +import * as Option from "effect/Option"; +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, type DeviceHostSummary } from "@t3tools/contracts"; +import { + checkDeviceHostConnections, + parseDeviceHostDraft, + deviceHostConnectionKey, + type DeviceHostCheck, +} from "./deviceHostConnectionChecks"; + +const host = { id: "mac", label: "Mac mini", target: "user@mac" }; +const ids = ["a", "b", "c", "d"].map((id) => EnvironmentId.make(id)); +const targets = ids.map((environmentId, index) => ({ + environmentId, + label: environmentId, + connected: index !== 3, +})); +const summary: DeviceHostSummary = { + id: "mac", + label: "Mac mini", + kind: "ssh", + platforms: [{ platform: "ios", available: true }], + hubInstalled: false, + agentDeviceInstalled: false, +}; + +describe("device host connection checks", () => { + it("starts all connected environments and retains success, local, failure, and offline results", async () => { + const calls: string[] = []; + const pending = new Map< + string, + { resolve: (value: DeviceHostSummary) => void; reject: (error: Error) => void } + >(); + const results = new Map(); + const run = checkDeviceHostConnections( + targets, + host, + (environmentId) => { + calls.push(environmentId); + return new Promise((resolve, reject) => pending.set(environmentId, { resolve, reject })); + }, + (environmentId, result) => results.set(environmentId, result), + ); + expect(calls).toEqual(ids.slice(0, 3)); + expect(results.get(ids[0]!)).toEqual({ status: "pending" }); + pending.get(ids[0]!)!.resolve(summary); + pending.get(ids[1]!)!.resolve({ ...summary, id: "local", kind: "local" }); + pending.get(ids[2]!)!.reject(new Error("SSH key rejected")); + await run; + expect([...results.values()]).toEqual([ + { status: "connected", platforms: summary.platforms }, + { status: "local" }, + { status: "failed", error: "SSH key rejected" }, + { status: "failed", error: "Environment disconnected" }, + ]); + }); + + it("does not reuse results after editing a destination or SSH options", () => { + const key = deviceHostConnectionKey(host); + for (const changed of [ + { ...host, target: "other" }, + { ...host, port: 2222 }, + { ...host, identityFile: "~/.ssh/other" }, + ]) { + expect(deviceHostConnectionKey(changed)).not.toBe(key); + } + expect( + deviceHostConnectionKey({ ...host, id: "another-environment-id", label: "Renamed" }), + ).toBe(key); + }); + it("validates SSH targets and normalizes optional identity files through the host contract", () => { + for (const target of ["-invalid", "user@bad host", " "]) { + expect(parseDeviceHostDraft({ ...host, target })._tag).toBe("None"); + } + for (const port of [0, 65536, 1.5]) { + expect(parseDeviceHostDraft({ ...host, port })._tag).toBe("None"); + } + expect(parseDeviceHostDraft({ ...host, target: " user@mac ", identityFile: " " })).toEqual( + Option.some(host), + ); + expect(parseDeviceHostDraft({ ...host, identityFile: " ~/.ssh/device " })).toEqual( + Option.some({ ...host, identityFile: "~/.ssh/device" }), + ); + }); +}); diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts new file mode 100644 index 000000000000..97312b671513 --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -0,0 +1,61 @@ +import { + type DeviceHostSummary, + type DevicePlatformAvailability, + type EnvironmentId, + SshDeviceHostConfig, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface DeviceHostCheckTarget { + environmentId: EnvironmentId; + label: string; + connected: boolean; +} +export type DeviceHostCheck = + | { status: "pending" } + | { status: "local" } + | { status: "connected"; platforms: ReadonlyArray } + | { status: "failed"; error: string }; + +const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); + +export function parseDeviceHostDraft(host: SshDeviceHostConfig) { + const { identityFile, ...rest } = host; + return decodeDeviceHostDraft({ + ...rest, + ...(identityFile?.trim() ? { identityFile: identityFile.trim() } : {}), + }); +} + +export function deviceHostConnectionKey(host: SshDeviceHostConfig) { + return JSON.stringify([host.target.trim(), host.port, host.identityFile?.trim() || undefined]); +} + +/** Each environment settles independently so one failure cannot hide the other results. */ +export async function checkDeviceHostConnections( + targets: ReadonlyArray, + host: SshDeviceHostConfig, + probe: (environmentId: EnvironmentId, host: SshDeviceHostConfig) => Promise, + report: (environmentId: EnvironmentId, result: DeviceHostCheck) => void, +) { + await Promise.all( + targets.map(async (target) => { + report(target.environmentId, { status: "pending" }); + try { + if (!target.connected) throw new Error("Environment disconnected"); + const result = await probe(target.environmentId, host); + report( + target.environmentId, + result.kind === "local" + ? { status: "local" } + : { status: "connected", platforms: result.platforms }, + ); + } catch (error) { + report(target.environmentId, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 6b4a5f9cdd82..ea9e1a88ec30 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -173,6 +173,28 @@ describe("searchSettings", () => { expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("keeps the local toggle searchable without offering hidden host publishing controls", () => { + const availability = { + hasCloudPublicConfig: true, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const remoteOnly = filterAvailableSettingsSearchItems({ + ...availability, + localEnvironmentDisabled: true, + }).map((item) => item.id); + expect(remoteOnly).toContain("local-environment"); + expect(remoteOnly).not.toContain("t3-connect"); + expect(remoteOnly).not.toContain("publish-agent-activity"); + expect(remoteOnly).not.toContain("wsl-backend"); + // Browsers without access:write still render CloudLinkRow for their host. + const browser = filterAvailableSettingsSearchItems(availability).map((item) => item.id); + expect(browser).toContain("publish-agent-activity"); + }); + it("shows automatic settlement settings when the server supports them", () => { const available = filterAvailableSettingsSearchItems({ hasCloudPublicConfig: false, @@ -339,7 +361,7 @@ describe("settings search targets", () => { expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true); expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true); expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false); - const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!; + const streaming = getSettingsSearchTargetScope("response-streaming")!; expect(streaming.scope).toBe("project-defaults"); expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 373ab7885c42..19268e2cdaed 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -52,11 +52,13 @@ export interface SettingsSearchItem { readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; + readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; readonly requiresThreadAutoSettlement?: boolean; } export interface SettingsSearchAvailability { + readonly localEnvironmentDisabled?: boolean; readonly hasCloudPublicConfig: boolean; readonly hasEnvironment: boolean; readonly hasProviderSettingsEnvironment: boolean; @@ -159,14 +161,6 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Panel animations", to: "/settings/appearance", }, - { - id: "compact-sidebar", - title: "Compact sidebar", - to: "/settings/appearance", - searchTerms: [ - "collapsed icons rail hover navigation preview expanded dense density one line rows chats threads compact thread list", - ], - }, { id: "environment-identification", title: "Environment identification", @@ -261,6 +255,13 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"], }, + { + id: "response-streaming", + title: "Response streaming", + to: "/settings/general", + scope: "project-defaults", + searchTerms: ["output token paragraph buffered wait turn legacy"], + }, { id: "hide-whitespace-changes", title: "Hide whitespace changes", @@ -398,13 +399,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["composer meter usage tokens circle old"], }, - { - id: "legacy-token-streaming", - title: "Stream token by token (legacy)", - to: "/settings/general", - scope: "project-defaults", - searchTerms: ["response output old compatibility"], - }, { id: "legacy-sidebar", title: "Sidebar (legacy)", @@ -634,6 +628,14 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"], localBackendManagementOnly: true, }, + { + id: "local-environment", + title: "Local environment", + to: "/settings/connections", + targetId: "connections-environment", + searchTerms: ["turn off on disable enable local server agents remote only restart"], + desktopOnly: true, + }, { id: "network-access", title: "Network access", @@ -665,6 +667,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "t3-connect", + localEnvironmentOnly: true, title: "T3 Connect", to: "/settings/connections", targetId: "connections-environment", @@ -674,6 +677,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "publish-agent-activity", + localEnvironmentOnly: true, title: "Publish agent activity", to: "/settings/connections", targetId: "connections-environment", @@ -682,7 +686,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "connections-environment", - title: "This environment", + title: "This machine", to: "/settings/connections", searchTerms: [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", @@ -690,7 +694,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "remote-environments", - title: "Remote environments", + title: "Environments", to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, @@ -704,7 +708,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "github-routing", - title: "GitHub routing", + title: "GitHub sharing", to: "/settings/connections", searchTerms: ["pull request trusted environments shared credentials permissions read actions"], }, @@ -848,6 +852,7 @@ export function filterAvailableSettingsSearchItems( (!item.environmentOnly || availability.hasEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && + (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), ); diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index b4a892f45270..a0601e41729f 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -3,6 +3,7 @@ import { AuthAccessWriteScope } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; +import { isLocalEnvironmentDisabled } from "~/localEnvironment"; import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; @@ -17,16 +18,21 @@ import { export function useAvailableSettingsSearchItems() { const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); - const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); + const localEnvironmentDisabled = isLocalEnvironmentDisabled(); + const desktopWsl = useEnvironmentQuery( + isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null, + ); const canManageLocalBackend = - isElectron || - ((primarySessionState.data?.authenticated && - primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? - false); + !localEnvironmentDisabled && + (isElectron || + ((primarySessionState.data?.authenticated && + primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? + false)); return useMemo( () => filterAvailableSettingsSearchItems({ + localEnvironmentDisabled, hasCloudPublicConfig: hasCloudPublicConfig(), hasEnvironment: environments.some((environment) => environment.serverConfig !== null), hasProviderSettingsEnvironment: environments.some((environment) => @@ -43,6 +49,12 @@ export function useAvailableSettingsSearchItems() { hasThreadAutoSettlement: getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0, }), - [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments], + [ + canManageLocalBackend, + desktopWsl.data, + desktopWsl.error, + environments, + localEnvironmentDisabled, + ], ); } diff --git a/apps/web/src/components/settings/useHostConnectionChecks.ts b/apps/web/src/components/settings/useHostConnectionChecks.ts new file mode 100644 index 000000000000..7d642fa62a60 --- /dev/null +++ b/apps/web/src/components/settings/useHostConnectionChecks.ts @@ -0,0 +1,46 @@ +import { useRef, useState } from "react"; +import * as Cause from "effect/Cause"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import { deviceEnvironment } from "../../state/device"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + checkDeviceHostConnections, + deviceHostConnectionKey, + type DeviceHostCheck, + type DeviceHostCheckTarget, +} from "./deviceHostConnectionChecks"; + +export function useHostConnectionChecks(targets: ReadonlyArray) { + const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); + const [checks, setChecks] = useState>>({}); + const running = useRef(new Set()); + const testConnection = async (host: SshDeviceHostConfig) => { + const key = deviceHostConnectionKey(host); + if (running.current.has(key)) return; + running.current.add(key); + setChecks((current) => ({ ...current, [key]: {} })); + const results: Record = {}; + try { + await checkDeviceHostConnections( + targets, + host, + async (environmentId, input) => { + const result = await test({ environmentId, input }); + if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause)); + return result.value; + }, + (environmentId, result) => { + results[environmentId] = result; + setChecks((current) => ({ + ...current, + [key]: { ...current[key], [environmentId]: result }, + })); + }, + ); + return results; + } finally { + running.current.delete(key); + } + }; + return { checks, testConnection }; +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 2f65cf8c3697..afbbf7671dfc 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + {currentFooterPage ? ( - + - Back + Back ) : ( @@ -224,10 +224,8 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( -
    - - -
    + +
    ); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx deleted file mode 100644 index 7327af79e794..000000000000 --- a/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { act, memo } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { SidebarCompletedTime } from "./SidebarCompletedTime"; - -let renderer: ReactTestRenderer | undefined; - -beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-07T01:01:00Z")); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", { - setTimeout, - clearTimeout, - setInterval, - clearInterval, - }); -}); - -afterEach(async () => { - await act(() => renderer?.unmount()); - renderer = undefined; - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); - -it("advances visible and accessible completion times without rerendering its memoized row", async () => { - const rowRender = vi.fn(); - const Row = memo(function Row() { - rowRender(); - return ; - }); - await act(() => { - renderer = create(); - }); - expect(renderer!.root.findByType("time").props.dateTime).toBe("2026-09-07T01:00:00Z"); - expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); - expect( - renderer!.root.findAll((node) => node.props.role === "status" || node.props["aria-live"]), - ).toHaveLength(0); - expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ - "1m", - ]); - - await act(() => vi.advanceTimersByTime(60_000)); - - expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); - expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ - "2m", - ]); - expect(rowRender).toHaveBeenCalledTimes(1); - await act(() => renderer!.unmount()); - renderer = undefined; - expect(vi.getTimerCount()).toBe(0); -}); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx deleted file mode 100644 index 785b8d9459d2..000000000000 --- a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useNowMinute } from "../../hooks/useNowMinute"; -import { formatRelativeTimeLabel } from "../../timestampFormat"; - -export function SidebarCompletedTime({ completedAt }: { completedAt: string }) { - // Subscribe inside the label so time advances even when the row is memoized. - const nowMinute = useNowMinute(); - const relativeTime = formatRelativeTimeLabel(completedAt, Date.parse(`${nowMinute}:00Z`)); - const label = relativeTime === "just now" ? "now" : relativeTime.replace(/ ago$/, ""); - - return ( - - ); -} diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index d0718d9856f8..878235615b39 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -20,10 +20,9 @@ import { } from "react"; import { cn } from "~/lib/utils"; -import { useCompactSidebarEnabled } from "../../hooks/useSettings"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { SidebarMenuButton, useSidebar } from "../ui/sidebar"; +import { SidebarMenuButton } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface SidebarThreadHeaderProps { @@ -70,9 +69,6 @@ export function SidebarThreadHeader({ activeSearchResultIndex, onClearSearch, }: SidebarThreadHeaderProps) { - const compactEnabled = useCompactSidebarEnabled(); - const { state, isMobile, setOpen } = useSidebar(); - const compact = compactEnabled && state === "collapsed" && !isMobile; const resultsVisible = isSearching && searchResultCount > 0; // Results shrink as the query narrows, so the active index can outrun the // list; pointing aria-activedescendant at a removed option strands the @@ -83,24 +79,10 @@ export function SidebarThreadHeader({ : "New thread"; return ( -
    - {compact ? ( - { - setOpen(true); - requestAnimationFrame(() => searchInputRef.current?.focus()); - }} - > - - - ) : null} +
    {/* Segmented well: the icons read as one control instead of three loose buttons competing with the search field beside them. */} -
    +
    {hasProjects ? ( <> {projectScope} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 8c04eec6fe7d..a94b7801ecfd 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -348,7 +348,7 @@ function SidebarUpdateControl() { ); return ( - + { diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 404295f5f5c1..307feda7abfc 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -591,11 +591,9 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps & { fixedHeader?: React.ReactNode; - fixedFooter?: React.ReactNode; }) { return ( <> @@ -619,7 +617,6 @@ function SidebarContent({ {...props} /> - {fixedFooter ?
    {fixedFooter}
    : null} ); } diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 7e88c4aae3c7..bcc2849dd041 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -51,6 +51,7 @@ import { } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; import { @@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( const platformConnectionSourceLayer = Layer.effect( PlatformConnectionSource, Effect.gen(function* () { - if (isHostedStaticApp()) { + if (isHostedStaticApp() || isLocalEnvironmentDisabled()) { return PlatformConnectionSource.of({ registrations: Stream.empty, }); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 2ceacd1a2cb0..ce06fbf5da05 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -307,13 +307,13 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } -async function bootstrapServerAuth(): Promise { - const bootstrapCredential = getDesktopBootstrapCredential(); +async function bootstrapServerAuth(urlCredential: string | null): Promise { const currentSession = await fetchSessionState(); - if (currentSession.authenticated) { + if (currentSession.authenticated && !urlCredential) { return { status: "authenticated" }; } + const bootstrapCredential = urlCredential ?? getDesktopBootstrapCredential(); if (!bootstrapCredential) { return { status: "requires-auth", @@ -428,19 +428,32 @@ export async function revokeOtherServerClientSessions(): Promise { } export async function resolveInitialServerAuthGateState(): Promise { - if (resolvedAuthenticatedGateState?.status === "authenticated") { - return resolvedAuthenticatedGateState; - } + const urlCredential = takePairingTokenFromUrl(); + const previousPromise = bootstrapPromise; + if (urlCredential) { + resolvedAuthenticatedGateState = null; + } else { + if (previousPromise) { + return previousPromise; + } - if (bootstrapPromise) { - return bootstrapPromise; + if (resolvedAuthenticatedGateState?.status === "authenticated") { + return resolvedAuthenticatedGateState; + } } - const nextPromise = bootstrapServerAuth(); + const nextPromise = previousPromise + ? previousPromise + .catch(() => undefined) + .then(() => { + resolvedAuthenticatedGateState = null; + return bootstrapServerAuth(urlCredential); + }) + : bootstrapServerAuth(urlCredential); bootstrapPromise = nextPromise; return nextPromise .then((result) => { - if (result.status === "authenticated") { + if (bootstrapPromise === nextPromise && result.status === "authenticated") { resolvedAuthenticatedGateState = result; } return result; diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index b08717d7c413..c9da4dab7051 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -158,7 +158,7 @@ describe("environmentBootstrap", () => { it("keeps an uppercase wss scheme secure when deriving the http url", () => { vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com"); - expect(readPrimaryEnvironmentTarget().target).toEqual({ + expect(readPrimaryEnvironmentTarget()?.target).toEqual({ httpBaseUrl: "https://remote.example.com/", wsBaseUrl: "wss://remote.example.com/", }); @@ -167,7 +167,7 @@ describe("environmentBootstrap", () => { it("keeps an uppercase https scheme secure when deriving the websocket url", () => { vi.stubEnv("VITE_HTTP_URL", "HTTPS://remote.example.com"); - expect(readPrimaryEnvironmentTarget().target).toEqual({ + expect(readPrimaryEnvironmentTarget()?.target).toEqual({ httpBaseUrl: "https://remote.example.com/", wsBaseUrl: "wss://remote.example.com/", }); @@ -257,6 +257,22 @@ describe("environmentBootstrap", () => { }); }); + it("has no primary target when the desktop local environment is disabled", () => { + vi.stubGlobal("window", { + location: new URL("t3code://app/"), + desktopBridge: { + getLocalEnvironmentEnabled: () => false, + getLocalEnvironmentBootstraps: () => [], + }, + }); + + expect(readPrimaryEnvironmentTarget()).toBeNull(); + expect(getPrimaryKnownEnvironment()).toBeNull(); + expect(() => resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toThrow( + "The local environment is disabled.", + ); + }); + it("preserves an unsupported window-origin protocol", () => { vi.stubGlobal("window", { location: { origin: "file:///tmp/t3code/" }, diff --git a/apps/web/src/environments/primary/sessionState.ts b/apps/web/src/environments/primary/sessionState.ts index 971f6811b8eb..5912a4865f9c 100644 --- a/apps/web/src/environments/primary/sessionState.ts +++ b/apps/web/src/environments/primary/sessionState.ts @@ -5,10 +5,15 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { fetchSessionState } from "./auth"; -const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe( +const primarySessionStateAtom = Atom.make( + Effect.suspend(() => + isLocalEnvironmentDisabled() ? Effect.succeed(null) : Effect.promise(fetchSessionState), + ), +).pipe( Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), Atom.withLabel("primary-environment:session"), diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index face3fa1e4fe..7e574bb7cd20 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,6 +1,8 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; + const PrimaryEnvironmentTargetSource = Schema.Literals([ "configured", "window-origin", @@ -57,6 +59,15 @@ export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErr } } +export class PrimaryEnvironmentDisabledError extends Schema.TaggedError()( + "PrimaryEnvironmentDisabledError", + {}, +) { + override get message(): string { + return "The local environment is disabled."; + } +} + export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( PrimaryEnvironmentProtocolUnsupportedError, @@ -276,6 +287,9 @@ export function resolvePrimaryEnvironmentHttpUrl( searchParams?: Record, ): string { const primaryTarget = readPrimaryEnvironmentTarget(); + if (!primaryTarget) { + throw new PrimaryEnvironmentDisabledError(); + } const url = parseTargetUrl({ rawValue: resolveHttpRequestBaseUrl(primaryTarget), @@ -289,7 +303,12 @@ export function resolvePrimaryEnvironmentHttpUrl( return url.toString(); } -export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { +// Null only when the desktop app runs with its local environment disabled; +// every other host has a primary (falling back to the page origin). +export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { + if (isLocalEnvironmentDisabled()) { + return null; + } return ( resolveDesktopPrimaryTarget() ?? resolveConfiguredPrimaryTarget() ?? diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx b/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx new file mode 100644 index 000000000000..f584112df4db --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentDisconnectDelay.test.tsx @@ -0,0 +1,65 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useEnvironmentDisconnectDelay } from "./useEnvironmentDisconnectDelay"; + +const environmentId = EnvironmentId.make("remote"); +let renderer: ReactTestRenderer; +let elapsed = false; + +function Probe({ unavailableId }: { unavailableId: EnvironmentId | null }) { + const value = useEnvironmentDisconnectDelay(unavailableId); + useLayoutEffect(() => { + elapsed = value; + }); + return null; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + act(() => { + renderer = create(); + }); +}); + +afterEach(() => { + act(() => renderer.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("waits 20 seconds without restarting on renders for the same environment", () => { + act(() => vi.advanceTimersByTime(10_000)); + expect(elapsed).toBe(false); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(9_999)); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(elapsed).toBe(true); +}); + +it("cancels a brief outage and starts a fresh delay on the next outage", () => { + act(() => vi.advanceTimersByTime(10_000)); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(false); + act(() => renderer.update()); + act(() => vi.advanceTimersByTime(19_999)); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(elapsed).toBe(true); + act(() => renderer.update()); + expect(elapsed).toBe(false); +}); + +it("does not carry elapsed time to another environment", () => { + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(true); + act(() => renderer.update()); + expect(elapsed).toBe(false); + act(() => vi.advanceTimersByTime(20_000)); + expect(elapsed).toBe(true); +}); diff --git a/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts b/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts new file mode 100644 index 000000000000..17c6f8f87be8 --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentDisconnectDelay.ts @@ -0,0 +1,25 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; + +/** Wait through brief outages before offering to switch off the active environment. */ +export function useEnvironmentDisconnectDelay(unavailableEnvironmentId: EnvironmentId | null) { + const [delay, setDelay] = useState({ environmentId: unavailableEnvironmentId, elapsed: false }); + if (delay.environmentId !== unavailableEnvironmentId) { + setDelay({ environmentId: unavailableEnvironmentId, elapsed: false }); + } + + useEffect(() => { + if (unavailableEnvironmentId === null) return; + const timeout = setTimeout( + () => setDelay({ environmentId: unavailableEnvironmentId, elapsed: true }), + 20_000, + ); + return () => clearTimeout(timeout); + }, [unavailableEnvironmentId]); + + return ( + unavailableEnvironmentId !== null && + delay.environmentId === unavailableEnvironmentId && + delay.elapsed + ); +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index fa4b8bc8fd31..194cc36c55f4 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -379,13 +379,6 @@ export function useLegacySidebarEnabled(): boolean { return settingsHydrated && legacySidebarEnabled; } -/** Keep the default collapsed sidebar until persisted client settings hydrate. */ -export function useCompactSidebarEnabled(): boolean { - const settingsHydrated = useClientSettingsHydrated(); - const compactSidebarEnabled = useClientSettingsValue().compactSidebarEnabled; - return settingsHydrated && compactSidebarEnabled; -} - /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a7f6d91f2e40..e7c5d919a211 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1891,6 +1891,22 @@ code { background: transparent !important; } +/* Paragraphs and code blocks arrive in chunks while a response streams. Fade + each new block in so the chunk does not pop. @starting-style only applies + when an element is first inserted, and the rule is gated on data-streaming, + so opening a finished thread never replays the fade. Opacity only, one + shot, no layout change. */ +@media (prefers-reduced-motion: no-preference) { + .chat-markdown[data-streaming] > *, + .chat-markdown[data-streaming] .chat-markdown-shiki { + transition: opacity 600ms ease-out; + + @starting-style { + opacity: 0; + } + } +} + /* Diagnostics-style tables: row separators only, uppercase headers, and a scroll-fade container for horizontal overflow. The root chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns shrink to single diff --git a/apps/web/src/localEnvironment.ts b/apps/web/src/localEnvironment.ts new file mode 100644 index 000000000000..e277d3ebd4ce --- /dev/null +++ b/apps/web/src/localEnvironment.ts @@ -0,0 +1,9 @@ +/** + * True when the desktop app runs without its local server. The renderer then + * has no primary environment: it skips primary auth and discovery and only + * connects to saved remote environments. Always false in browsers and on + * desktop builds predating the setting. + */ +export function isLocalEnvironmentDisabled(): boolean { + return window.desktopBridge?.getLocalEnvironmentEnabled?.() === false; +} diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts index ca35f6322e3b..e8d74bfbd7ff 100644 --- a/apps/web/src/onboarding/firstRun.logic.test.ts +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -316,6 +316,18 @@ describe("resolveHostedFirstRunDecision", () => { }); }); + it("keeps Connections reachable for a remote-only desktop predating onboarding", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 0, + localEnvironmentDisabled: true, + }), + ).toEqual({ decision: "app", persistCompletion: true }); + }); + it("backfills onboarding for a hosted install with saved environments", () => { expect( resolveHostedFirstRunDecision({ diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts index 013dbb02d527..924c7b5b8f1f 100644 --- a/apps/web/src/onboarding/firstRun.logic.ts +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -49,6 +49,7 @@ interface FirstRunDecisionInput { } interface HostedFirstRunDecisionInput { + readonly localEnvironmentDisabled?: boolean; readonly hydrated: boolean; readonly completed: boolean; readonly catalogReady: boolean; @@ -178,7 +179,9 @@ export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput return { decision: "pending", persistCompletion: false }; } - return input.environmentCount === 0 + // An existing desktop may have disabled its server before onboarding existed. + // Keep Connections accessible so it can turn local execution back on. + return input.environmentCount === 0 && !input.localEnvironmentDisabled ? { decision: "wizard", persistCompletion: false } : { decision: "app", persistCompletion: true }; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 982d81420445..89a1ff23cc83 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -54,6 +54,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; import { useAtomCommand } from "../state/use-atom-command"; @@ -83,7 +84,7 @@ export const Route = createRootRoute({ }; } - if (isHostedStaticApp(new URL(window.location.href))) { + if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) { return { authGateState: { status: "hosted-static", diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index e6bc867e9820..b32524bdde1e 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,6 +4,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { isElectron } from "../env"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; @@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({ function HostedStaticOnboardingState() { const cloudEnabled = hasCloudPublicConfig(); + const localEnvironmentOff = isLocalEnvironmentDisabled(); + const description = localEnvironmentOff + ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections." + : cloudEnabled + ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." + : "Open Connections and add that machine using its pairing link. This app must be able to reach it."; return (
    - +
    {APP_DISPLAY_NAME} @@ -135,13 +143,11 @@ function HostedStaticOnboardingState() { Connect to a computer running T3 Code - This browser connects to T3 Code running on your computer or a server. Start the T3 - Code desktop app or command-line server on that machine and keep it running. + This app connects to T3 Code running on your computer or a server. Start the T3 Code + desktop app or command-line server on that machine and keep it running. - {cloudEnabled - ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." - : "Open Connections and add that machine using its pairing link. This browser must be able to reach it."} + {description}