From b3f8e35478c211ad5f954a7daecf00e6fa4794c4 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 4 Sep 2026 19:40:50 +0300 Subject: [PATCH 1/7] chore(ci): replace upstream automation --- .github/nix/release-common.nix | 25 + .github/workflows/ci.yml | 193 ++- .github/workflows/deploy-relay.yml | 3 +- .github/workflows/history-validation.yml | 72 + .github/workflows/issue-labels.yml | 2 +- .github/workflows/mobile-eas-preview.yml | 3 +- .github/workflows/mobile-eas-production.yml | 2 +- .../workflows/mobile-fingerprint-check.yml | 2 +- .../workflows/mobile-showcase-screenshots.yml | 33 +- .github/workflows/nix-pnpm-deps.yml | 262 ++++ .github/workflows/pr-size.yml | 6 +- .github/workflows/pr-vouch.yml | 4 +- .github/workflows/prepare-actualization.yml | 70 + .github/workflows/promote-history.yml | 220 +++ .github/workflows/publish-aur.yml | 65 - .github/workflows/release-build.yml | 408 ++++++ .github/workflows/release-pr.yml | 230 +++ .github/workflows/release-publish.yml | 236 ++++ .github/workflows/release-stable.yml | 19 + .github/workflows/release.yml | 1253 +---------------- .github/workflows/sync-upstream-main.yml | 134 ++ AGENTS.md | 2 + FORK.md | 36 + MAINTENANCE.md | 123 ++ README.md | 129 +- apps/server/src/cli/service.test.ts | 5 +- apps/server/src/cloud/cliAuthHtml.ts | 11 +- apps/server/vite.config.ts | 6 +- apps/web/vercel.ts | 18 +- infra/relay/scripts/deploy.test.ts | 23 - scripts/build-desktop-artifact.ts | 38 +- scripts/lib/brand-assets.ts | 4 +- scripts/render-upstream-release-notes.mjs | 45 + scripts/resolve-fork-stable-release.ts | 136 ++ scripts/resolve-nightly-release.ts | 22 +- scripts/resolve-previous-release-tag.ts | 38 +- scripts/validate-fork-history.ts | 244 ++++ 37 files changed, 2522 insertions(+), 1600 deletions(-) create mode 100644 .github/nix/release-common.nix create mode 100644 .github/workflows/history-validation.yml create mode 100644 .github/workflows/nix-pnpm-deps.yml create mode 100644 .github/workflows/prepare-actualization.yml create mode 100644 .github/workflows/promote-history.yml delete mode 100644 .github/workflows/publish-aur.yml create mode 100644 .github/workflows/release-build.yml create mode 100644 .github/workflows/release-pr.yml create mode 100644 .github/workflows/release-publish.yml create mode 100644 .github/workflows/release-stable.yml create mode 100644 .github/workflows/sync-upstream-main.yml create mode 100644 FORK.md create mode 100644 MAINTENANCE.md create mode 100755 scripts/render-upstream-release-notes.mjs create mode 100644 scripts/resolve-fork-stable-release.ts create mode 100644 scripts/validate-fork-history.ts diff --git a/.github/nix/release-common.nix b/.github/nix/release-common.nix new file mode 100644 index 000000000..5d2fe844a --- /dev/null +++ b/.github/nix/release-common.nix @@ -0,0 +1,25 @@ +{ repo, version }: + +let + flake = builtins.getFlake repo; + pkgs = import flake.inputs.nixpkgs { system = "x86_64-linux"; }; + runtime = pkgs.callPackage (flake.outPath + "/nix/package.nix") { + src = flake.outPath; + inherit version; + }; +in +runtime.overrideAttrs { + CI = "true"; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/apps/desktop" "$out/apps/server" "$out/wsl-prebuild" + cp -R apps/desktop/dist-electron "$out/apps/desktop/" + cp -R apps/desktop/resources "$out/apps/desktop/" + cp -R apps/server/dist "$out/apps/server/" + cp apps/server/node_modules/node-pty/build/Release/pty.node "$out/wsl-prebuild/" + + runHook postInstall + ''; +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e23dbd60d..7a797b104 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - "canary/**" permissions: contents: read @@ -16,8 +17,8 @@ concurrency: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 @@ -43,40 +44,60 @@ jobs: run-install: true - name: Ensure Electron runtime is installed + if: github.event_name == 'pull_request' run: vp run --filter @t3tools/desktop ensure:electron - # Files/dependencies are repo-wide; export checks cover clean workspaces only. - - name: Check unused code - run: vp run knip:check - - name: Check run: vp check - name: Typecheck run: vpr typecheck - - uses: ./.github/actions/setup-apt-mirrors - - name: Install browser secret helper build libraries - run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + if: github.event_name == 'pull_request' + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + # Release Build Jobs performs this exact build on branch pushes. PRs still + # exercise it here so broken build inputs never reach main. - name: Build desktop pipeline + if: github.event_name == 'pull_request' run: vp run build:desktop - name: Verify preload bundle output - run: node apps/desktop/scripts/verify-preload-bundle.mjs - - # Everything except `t3` (apps/server). `--parallel` drops the package - # dependency ordering that `vp run` applies by default: these `test` tasks - # declare no `dependsOn` and resolve workspace deps from source, so ordering - # only bought us idle runners between dependency layers. The concurrency - # limit stays at the default 4 so peak load per runner is unchanged. - test: - name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + if: github.event_name == 'pull_request' + run: | + test -f apps/desktop/dist-electron/preload.cjs + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs + grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs + + test_js: + name: Test shard (${{ matrix.shard }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - shard: server 1/3 + filters: --filter t3 + args: --shard=1/3 + artifact_suffix: server-1 + - shard: server 2/3 + filters: --filter t3 + args: --shard=2/3 + artifact_suffix: server-2 + - shard: server 3/3 + filters: --filter t3 + args: --shard=3/3 + artifact_suffix: server-3 + - shard: web + filters: --filter @t3tools/web + - shard: mobile + filters: --filter @t3tools/mobile + - shard: desktop + filters: --filter @t3tools/desktop + - shard: libraries + filters: --filter './packages/*' --filter './scripts' --filter './oxlint-plugin-t3code' --filter './infra/*' steps: - name: Checkout uses: actions/checkout@v6 @@ -94,104 +115,44 @@ jobs: run-install: true - name: Ensure Electron runtime is installed + if: matrix.shard == 'desktop' run: vp run --filter @t3tools/desktop ensure:electron - - uses: ./.github/actions/setup-apt-mirrors - - name: Install browser secret helper build libraries - run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - - name: Test nightly release checks - run: node --test .github/scripts/check-nightly-release.test.cjs - - - name: Test - run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test - - # apps/server sets `fileParallelism: false`, so its 239 files run strictly - # one at a time. Sharding spreads them over separate runners instead of - # separate workers, so no two server test files ever share a machine and the - # isolation that flag buys is preserved exactly. - test_server: - name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - 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: true + if: matrix.shard == 'desktop' + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # No Electron setup here: `t3` (apps/server) has no Electron dependency - # and none of its tests touch the runtime. Only the non-server `test` - # job, which covers @t3tools/desktop, needs the download. - - name: Test + - name: Test ${{ matrix.shard }} env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json - run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} - - # src/server.test.ts writes the budget report, so exactly one shard - # produces these files. Gating the upload on their presence keeps a - # single `thread-transfer-results` artifact per run, which is the name - # thread-transfer-report.yml resolves. - - name: Detect transfer budget report - id: transfer_budget - if: always() - run: | - if test -f "${{ runner.temp }}/thread-transfer-result.json"; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - fi + run: vp run ${{ matrix.filters }} test ${{ matrix.args }} - name: Publish transfer budget report - if: always() && steps.transfer_budget.outputs.present == 'true' + if: always() && startsWith(matrix.shard, 'server') run: | if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" - else - echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" fi - - name: Upload thread transfer result - if: always() && steps.transfer_budget.outputs.present == 'true' + - name: Upload sharded thread transfer result + if: always() && startsWith(matrix.shard, 'server') uses: actions/upload-artifact@v7 with: - name: thread-transfer-results + name: thread-transfer-results-${{ matrix.artifact_suffix }} path: ${{ runner.temp }}/thread-transfer-result.json if-no-files-found: ignore - retention-days: 30 + retention-days: 1 - # Split out of Check and Test: both paid ~7-9s to install a Rust toolchain - # for checks that take under 3s, on the critical path of every PR. - rust: - name: Rust - runs-on: blacksmith-4vcpu-ubuntu-2404 + test_native: + name: Test shard (resource monitor) + runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 with: - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + sparse-checkout: native/resource-monitor - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -204,13 +165,47 @@ jobs: - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + test: + name: Test + if: always() + needs: + - test_js + - test_native + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Download sharded thread transfer result + if: needs.test_js.result == 'success' + uses: actions/download-artifact@v8 + with: + pattern: thread-transfer-results-server-* + merge-multiple: true + path: ${{ runner.temp }}/thread-transfer + + - name: Upload thread transfer result + if: needs.test_js.result == 'success' + uses: actions/upload-artifact@v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/thread-transfer-result.json + if-no-files-found: error + retention-days: 30 + + - name: Check shard results + env: + JS_RESULT: ${{ needs.test_js.result }} + NATIVE_RESULT: ${{ needs.test_native.result }} + run: | + test "$JS_RESULT" = success + test "$NATIVE_RESULT" = success + # The static analysis below needs a macOS runner, which bills ~6.7x a Linux # minute, so gate it on the native sources it actually lints instead of paying # for it on every push. Detection is API-only (no checkout) and fails open: if # the diff cannot be resolved, the lint runs. mobile_native_changes: name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read @@ -288,7 +283,7 @@ jobs: # Skip only on an explicit "no": a gate job that failed or errored leaves the # output empty, and that must run the lint rather than silently skip it. if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -316,7 +311,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index f652844a5..1c72833dc 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -17,7 +17,8 @@ concurrency: jobs: deploy_relay: name: Deploy production relay - runs-on: blacksmith-8vcpu-ubuntu-2404 + if: ${{ false }} # Disabled for the fork: no production relay deployment. + runs-on: ubuntu-latest timeout-minutes: 15 environment: name: production diff --git a/.github/workflows/history-validation.yml b/.github/workflows/history-validation.yml new file mode 100644 index 000000000..6c96eb3d8 --- /dev/null +++ b/.github/workflows/history-validation.yml @@ -0,0 +1,72 @@ +name: History validation + +on: + push: + branches: + - main + - "canary/**" + pull_request: + types: [opened, synchronize, labeled, unlabeled, ready_for_review] + workflow_dispatch: + inputs: + base_ref: + description: "Fork history base branch" + required: false + default: upstream/main + type: string + +permissions: + contents: read + +jobs: + validate: + name: History / validate + if: >- + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'actualization') || + contains(github.event.pull_request.labels.*.name, 'release') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Fetch history base + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + DISPATCH_BASE_REF: ${{ inputs.base_ref }} + run: | + set -euo pipefail + if [[ "$REF_NAME" == canary/* ]]; then + upstream_branch="t3code/${REF_NAME#canary/}" + git fetch --no-tags https://github.com/pingdotgg/t3code.git \ + "$upstream_branch:refs/remotes/upstream/$upstream_branch" + echo "HISTORY_BASE_REF=refs/remotes/upstream/$upstream_branch" >> "$GITHUB_ENV" + exit 0 + elif [[ "$EVENT_NAME" == pull_request ]]; then + base_ref="$PR_BASE_REF" + elif [[ -n "$DISPATCH_BASE_REF" ]]; then + base_ref="$DISPATCH_BASE_REF" + else + base_ref=upstream/main + fi + git fetch --no-tags origin "$base_ref:refs/remotes/origin/$base_ref" + echo "HISTORY_BASE_REF=refs/remotes/origin/$base_ref" >> "$GITHUB_ENV" + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Validate fork history + run: node scripts/validate-fork-history.ts --ref HEAD --upstream-ref "$HISTORY_BASE_REF" diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml index d6571d65d..00c7169b7 100644 --- a/.github/workflows/issue-labels.yml +++ b/.github/workflows/issue-labels.yml @@ -15,7 +15,7 @@ permissions: jobs: sync: name: Sync issue labels - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Ensure managed issue labels exist uses: actions/github-script@v7 diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index d53602f8f..594785f73 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -8,9 +8,10 @@ jobs: preview: name: EAS Preview if: | + github.repository_owner == 'pingdotgg' && contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-latest concurrency: group: mobile-eas-preview-${{ github.event.pull_request.number }} cancel-in-progress: true diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 4ad9f4f76..66802729b 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -76,7 +76,7 @@ concurrency: jobs: production: name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read env: diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index fd98817cd..a2de44a09 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -32,7 +32,7 @@ concurrency: jobs: fingerprint: name: Native fingerprint diff - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read issues: write diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index c64bccacd..460dd706e 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -21,19 +21,6 @@ on: - both - dark - light - theme: - description: Palette to capture (all multiplies the run by six) - required: true - default: t3-code - type: choice - options: - - t3-code - - t3-chat - - grove - - ocean - - ember - - iris - - all permissions: contents: read @@ -45,10 +32,8 @@ jobs: ios: name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 - # Capturing every palette multiplies the device matrix by six, and only the - # one native build is shared between them. - timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} + runs-on: macos-26 + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v6 @@ -77,10 +62,10 @@ jobs: "$vp_pnpm_bin/pnpm" --version - name: Capture iOS showcase - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" - name: Validate App Store Connect assets - run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only - name: Upload iOS screenshots if: always() @@ -94,10 +79,8 @@ jobs: android: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' - runs-on: blacksmith-16vcpu-ubuntu-2404 - # Capturing every palette multiplies the device matrix by six, and only the - # one native build is shared between them. - timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 env: T3_SHOWCASE_ANDROID_ABI: x86_64 steps: @@ -154,10 +137,10 @@ jobs: cores: 8 ram-size: 4096M disable-animations: false - script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" + script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" - name: Validate Google Play assets - run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only + run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only - name: Upload Android screenshots if: always() diff --git a/.github/workflows/nix-pnpm-deps.yml b/.github/workflows/nix-pnpm-deps.yml new file mode 100644 index 000000000..f7337b29f --- /dev/null +++ b/.github/workflows/nix-pnpm-deps.yml @@ -0,0 +1,262 @@ +name: Nix pnpm dependency hash + +on: + issue_comment: + types: [created] + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +concurrency: + group: nix-pnpm-dependency-hash-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + name: Check PR pnpm dependency hash + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout pull request + uses: actions/checkout@v6 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - id: update + name: Calculate pnpm dependency hash fix + shell: bash + run: | + nix run github:Mic92/nix-update/cf68051e7b7e08de6c707484cdcd1a53feb48e05 -- \ + t3code-runtime \ + --flake \ + --version=skip \ + --override-filename nix/package.nix \ + --build + + git reset -- flake.lock + git clean -f -- flake.lock + + if git diff --quiet -- nix/package.nix; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + { + echo 'The Nix pnpm dependency hash is stale.' + echo + echo "The PR author or a maintainer can apply this change by commenting \`/apply-nix-fix\`. Fork pull requests must allow maintainer edits." + echo + echo '```diff' + git diff -- nix/package.nix + echo '```' + } | tee "$RUNNER_TEMP/nix-pnpm-dependency-hash.md" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Post proposed hash change + if: >- + steps.update.outputs.changed == 'true' && + github.event.pull_request.head.repo.full_name == github.repository + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: nix-pnpm-dependency-hash + path: ${{ runner.temp }}/nix-pnpm-dependency-hash.md + + - name: Remove resolved hash comment + if: >- + steps.update.outputs.changed == 'false' && + github.event.pull_request.head.repo.full_name == github.repository + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: nix-pnpm-dependency-hash + delete: true + + - name: Fail on dependency hash drift + if: steps.update.outputs.changed == 'true' + run: | + echo '::error::The Nix pnpm dependency hash is stale. Comment /apply-nix-fix or apply the change from the job summary.' + exit 1 + + apply: + name: Apply PR pnpm dependency hash + if: >- + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.issue.state == 'open' && + github.event.comment.body == '/apply-nix-fix' && + (github.actor == github.event.issue.user.login || + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + steps: + - id: pull_request + name: Authorize command and read pull request + uses: actions/github-script@v8 + with: + script: | + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + const headRepository = pullRequest.head.repo.full_name; + + if (headRepository !== context.payload.repository.full_name && !pullRequest.maintainer_can_modify) { + throw new Error(`Pull request #${pullRequest.number} does not allow maintainer edits.`); + } + + core.setOutput("head-repository", headRepository); + core.setOutput("head-ref", pullRequest.head.ref); + core.setOutput("head-sha", pullRequest.head.sha); + + - name: Checkout pull request + uses: actions/checkout@v6 + with: + repository: ${{ steps.pull_request.outputs.head-repository }} + ref: ${{ steps.pull_request.outputs.head-sha }} + persist-credentials: false + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - id: update + name: Calculate pnpm dependency hash fix + shell: bash + run: | + nix run github:Mic92/nix-update/cf68051e7b7e08de6c707484cdcd1a53feb48e05 -- \ + t3code-runtime \ + --flake \ + --version=skip \ + --override-filename nix/package.nix \ + --build + + git reset -- flake.lock + git clean -f -- flake.lock + + if git diff --quiet -- nix/package.nix; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo 'The Nix pnpm dependency hash is already current.' >> "$GITHUB_STEP_SUMMARY" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + git add -- nix/package.nix + fi + + # PR-controlled code is inspected above. Mint the write token only after that work finishes. + - id: app_token + name: Mint release app token + if: steps.update.outputs.changed == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Commit dependency hash fix + if: steps.update.outputs.changed == 'true' + env: + APP_SLUG: ${{ steps.app_token.outputs.app-slug }} + GH_TOKEN: ${{ steps.app_token.outputs.token }} + HEAD_REF: ${{ steps.pull_request.outputs.head-ref }} + HEAD_REPOSITORY: ${{ steps.pull_request.outputs.head-repository }} + shell: bash + run: | + bot_name="${APP_SLUG}[bot]" + bot_id="$(gh api "/users/${bot_name}" --jq '.id')" + + git config user.name "$bot_name" + git config user.email "${bot_id}+${bot_name}@users.noreply.github.com" + git -c core.hooksPath=/dev/null -c commit.gpgsign=false \ + commit -m 'fix(nix): update pnpm dependency hash' + git -c core.hooksPath=/dev/null \ + push "https://x-access-token:${GH_TOKEN}@github.com/${HEAD_REPOSITORY}.git" \ + "HEAD:refs/heads/${HEAD_REF}" + + update: + name: Check pnpm dependency hash + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout default branch + uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + token: ${{ steps.app_token.outputs.token }} + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - id: update + name: Update pnpm dependency hash + shell: bash + run: | + nix run github:Mic92/nix-update/cf68051e7b7e08de6c707484cdcd1a53feb48e05 -- \ + t3code-runtime \ + --flake \ + --version=skip \ + --override-filename nix/package.nix \ + --build + + git reset -- flake.lock + git clean -f -- flake.lock + + if git diff --quiet -- nix/package.nix; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - id: pull_request + name: Create or update dependency hash PR + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.app_token.outputs.token }} + base: ${{ github.ref_name }} + branch: automation/nix-pnpm-dependency-hash-${{ github.ref_name }} + delete-branch: true + add-paths: nix/package.nix + commit-message: update nix pnpm dependency hash + title: update nix pnpm dependency hash + body: | + ## What Changed + + - Updated the Nix pnpm dependency hash for the current lockfile. + + ## Why + + The committed hash did not match the dependency store generated from `pnpm-lock.yaml`. + + ## Checklist + + - [x] This PR is small and focused + - [x] I explained what changed and why + + - name: Fail on dependency hash drift + if: steps.update.outputs.changed == 'true' + env: + PULL_REQUEST_URL: ${{ steps.pull_request.outputs.pull-request-url }} + run: | + echo "::error::The Nix pnpm dependency hash was stale. Repair PR: $PULL_REQUEST_URL" + exit 1 diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index af557dff6..4c810069d 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -10,7 +10,7 @@ permissions: jobs: prepare-config: name: Prepare PR size config - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest outputs: labels_json: ${{ steps.config.outputs.labels_json }} steps: @@ -58,7 +58,7 @@ jobs: name: Sync PR size label definitions needs: prepare-config if: github.event_name != 'pull_request_target' - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest permissions: contents: read issues: write @@ -115,7 +115,7 @@ jobs: name: Label PR size needs: prepare-config if: github.event_name == 'pull_request_target' - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest permissions: contents: read issues: read diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml index c4abb08b7..51170a6f2 100644 --- a/.github/workflows/pr-vouch.yml +++ b/.github/workflows/pr-vouch.yml @@ -20,7 +20,7 @@ permissions: jobs: collect-targets: name: Collect PR targets - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest outputs: targets: ${{ steps.collect.outputs.targets }} steps: @@ -66,7 +66,7 @@ jobs: name: Label PR ${{ matrix.target.number }} needs: collect-targets if: ${{ needs.collect-targets.outputs.targets != '[]' }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest concurrency: group: pr-vouch-${{ matrix.target.number }} cancel-in-progress: true diff --git a/.github/workflows/prepare-actualization.yml b/.github/workflows/prepare-actualization.yml new file mode 100644 index 000000000..5f8e29667 --- /dev/null +++ b/.github/workflows/prepare-actualization.yml @@ -0,0 +1,70 @@ +name: Prepare actualization + +on: + workflow_dispatch: + inputs: + head_branch: + description: "Pushed branch containing the rebuilt history" + required: true + type: string + title: + description: "Optional pull request title" + required: false + default: "actualize fork on current upstream" + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + name: Create actualization PR + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - id: refs + name: Resolve actualization bases + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + HEAD_BRANCH: ${{ inputs.head_branch }} + run: | + set -euo pipefail + main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha)" + upstream_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/upstream/main" --jq .object.sha)" + live_upstream_sha="$(git ls-remote https://github.com/pingdotgg/t3code.git refs/heads/main | awk '{print $1}')" + if [[ "$upstream_sha" != "$live_upstream_sha" ]]; then + echo "upstream/main is stale at $upstream_sha; sync it before preparing actualization (live: $live_upstream_sha)." >&2 + exit 1 + fi + { + echo "base_ref=upstream/main" + echo "main_sha=$main_sha" + echo "upstream_sha=$upstream_sha" + } >> "$GITHUB_OUTPUT" + + - name: Open draft actualization PR + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + HEAD_BRANCH: ${{ inputs.head_branch }} + UPSTREAM_SHA: ${{ steps.refs.outputs.upstream_sha }} + MAIN_SHA: ${{ steps.refs.outputs.main_sha }} + TITLE: ${{ inputs.title }} + run: | + set -euo pipefail + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --base upstream/main \ + --head "$HEAD_BRANCH" \ + --title "$TITLE" \ + --label actualization \ + --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. Promote with /promote after the checks pass.\n\n' diff --git a/.github/workflows/promote-history.yml b/.github/workflows/promote-history.yml new file mode 100644 index 000000000..03216d759 --- /dev/null +++ b/.github/workflows/promote-history.yml @@ -0,0 +1,220 @@ +name: Promote history PR + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + promote: + if: >- + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/promote') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - id: pr + name: Resolve promotion request + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + COMMENT_BODY: ${{ github.event.comment.body }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + set -euo pipefail + if [[ "$(printf '%s' "$COMMENT_BODY" | tr -d '[:space:]')" != '/promote' ]]; then + echo 'Promotion command must be /promote.' >&2 + exit 1 + fi + pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")" + if [[ "$(jq -r .draft <<<"$pr_json")" != true ]]; then + echo 'Promotion PR must remain a draft.' >&2 + exit 1 + fi + if [[ "$(jq -r .head.repo.full_name <<<"$pr_json")" != "$GITHUB_REPOSITORY" ]]; then + echo 'Promotion PR must come from this repository.' >&2 + exit 1 + fi + labels="$(jq -r '[.labels[].name] | join(" ")' <<<"$pr_json")" + kind="" + if [[ " $labels " == *' actualization '* ]]; then kind=actualization; fi + if [[ " $labels " == *' release '* ]]; then + if [[ -n "$kind" ]]; then + echo 'Promotion PR cannot have both actualization and release labels.' >&2 + exit 1 + fi + kind=release + fi + if [[ -z "$kind" ]]; then + echo 'Promotion PR needs an actualization or release label.' >&2 + exit 1 + fi + base_ref="$(jq -r .base.ref <<<"$pr_json")" + if [[ "$kind" == actualization ]]; then + if [[ "$base_ref" != upstream/main ]]; then + echo 'Actualization PR must target upstream/main.' >&2 + exit 1 + fi + expected_main_sha="$(jq -r '.body // ""' <<<"$pr_json" | grep -oE '' | sed -E 's/.*: ([0-9a-f]{40}) -->/\1/' | head -n1 || true)" + if [[ ! "$expected_main_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo 'Actualization PR is missing its main history marker.' >&2 + exit 1 + fi + else + if [[ "$base_ref" != main ]]; then + echo 'Release PR must target main.' >&2 + exit 1 + fi + expected_main_sha="$(jq -r .base.sha <<<"$pr_json")" + fi + { + echo "number=$PR_NUMBER" + echo "kind=$kind" + echo "base_sha=$(jq -r .base.sha <<<"$pr_json")" + echo "base_ref=$base_ref" + echo "expected_main_sha=$expected_main_sha" + echo "validation_ref=$base_ref" + echo "head_sha=$(jq -r .head.sha <<<"$pr_json")" + echo "head_ref=$(jq -r .head.ref <<<"$pr_json")" + } >> "$GITHUB_OUTPUT" + + - name: Checkout promotion head + uses: actions/checkout@v6 + with: + ref: ${{ steps.pr.outputs.head_sha }} + fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + + - name: Fetch promotion base + env: + BASE_REF: ${{ steps.pr.outputs.base_ref }} + run: git fetch --no-tags origin "$BASE_REF:refs/remotes/origin/$BASE_REF" + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Verify main has not moved + env: + EXPECTED_MAIN_SHA: ${{ steps.pr.outputs.expected_main_sha }} + KIND: ${{ steps.pr.outputs.kind }} + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + run: | + set -euo pipefail + git fetch origin main + current_main="$(git rev-parse refs/remotes/origin/main)" + if [[ "$current_main" != "$EXPECTED_MAIN_SHA" ]]; then + echo "main moved from $EXPECTED_MAIN_SHA to $current_main; refresh the PR before promoting" >&2 + exit 1 + fi + if [[ "$KIND" == actualization ]]; then + current_upstream="$(git rev-parse refs/remotes/origin/upstream/main)" + if [[ "$current_upstream" != "$BASE_SHA" ]]; then + echo "upstream/main moved from $BASE_SHA to $current_upstream; refresh the PR before promoting" >&2 + exit 1 + fi + fi + + - name: Verify required PR checks + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + run: | + set -euo pipefail + checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs?per_page=100")" + latest_checks="$( + jq ' + [.[].check_runs[]] + | sort_by(.name, .started_at) + | group_by(.name) + | map(max_by(.started_at)) + ' <<<"$checks" + )" + if ! jq -e 'length > 0 and all(.[]; .status == "completed" and (.conclusion == "success" or .conclusion == "skipped"))' <<<"$latest_checks" >/dev/null; then + echo 'All PR checks must be complete and successful before promotion.' >&2 + jq . <<<"$latest_checks" >&2 + exit 1 + fi + + - name: Verify candidate history + env: + VALIDATION_REF: ${{ steps.pr.outputs.validation_ref }} + run: node scripts/validate-fork-history.ts --ref HEAD --upstream-ref "refs/remotes/origin/$VALIDATION_REF" + + - name: Verify validated main for release promotion + if: steps.pr.outputs.kind == 'release' + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + run: | + set -euo pipefail + count="$( + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/commits/$BASE_SHA/check-runs?per_page=100" \ + | jq '[.[].check_runs[] | select(.name == "History / validate" and .conclusion == "success")] | length' + )" + if [[ "$count" -eq 0 ]]; then + echo 'Release promotion requires a validated main history.' >&2 + exit 1 + fi + + - id: push + name: Promote reviewed history + env: + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + EXPECTED_MAIN_SHA: ${{ steps.pr.outputs.expected_main_sha }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + KIND: ${{ steps.pr.outputs.kind }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + GIT_COMMITTER_NAME: ${{ steps.app_token.outputs.app-slug }}[bot] + GIT_COMMITTER_EMAIL: noreply@github.com + run: | + set -euo pipefail + if [[ "$KIND" == actualization ]]; then + promoted_sha="$HEAD_SHA" + else + parent="$(git rev-parse "$BASE_SHA^")" + tree="$(git rev-parse "$HEAD_SHA^{tree}")" + export GIT_AUTHOR_NAME="$(git show -s --format=%an "$HEAD_SHA")" + export GIT_AUTHOR_EMAIL="$(git show -s --format=%ae "$HEAD_SHA")" + export GIT_AUTHOR_DATE="$(git show -s --format=%aI "$HEAD_SHA")" + message="$(git show -s --format=%B "$HEAD_SHA")" + message+=$'\n\nRelease-PR: #'"$PR_NUMBER"$'\n' + promoted_sha="$(printf '%s' "$message" | git commit-tree "$tree" -p "$parent")" + fi + git push --force-with-lease="refs/heads/main:$EXPECTED_MAIN_SHA" origin "$promoted_sha:refs/heads/main" + echo "promoted_sha=$promoted_sha" >> "$GITHUB_OUTPUT" + + - name: Finalize promotion PR + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + KIND: ${{ steps.pr.outputs.kind }} + PROMOTED_SHA: ${{ steps.push.outputs.promoted_sha }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + BASE_REF: ${{ steps.pr.outputs.base_ref }} + run: | + set -euo pipefail + gh pr comment "$PR_NUMBER" --body "Promoted as $PROMOTED_SHA ($KIND)." + pr_state="$(gh pr view "$PR_NUMBER" --json state --jq .state)" + if [[ "$pr_state" == OPEN ]]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" -f state=closed >/dev/null + fi + gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/heads/$HEAD_REF" >/dev/null || true diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml deleted file mode 100644 index 62f8fd1f5..000000000 --- a/.github/workflows/publish-aur.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Publish AUR package - -# See packaging/aur/README.md. - -on: - workflow_call: - inputs: - release_tag: - required: true - type: string - pkgrel: - required: false - default: "1" - type: string - secrets: - AUR_SSH_PRIVATE_KEY: - required: true - workflow_dispatch: - inputs: - release_tag: - description: "Release tag to publish" - required: true - type: string - pkgrel: - description: "Arch package release override" - required: false - default: "1" - type: string - -permissions: - contents: read - -concurrency: - group: publish-aur - cancel-in-progress: false - -jobs: - publish: - name: Validate and publish - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 30 - container: - image: archlinux:base-devel - - steps: - - name: Install Arch packaging tools - run: pacman -Syu --noconfirm --needed git github-cli jq namcap openssh sudo - - - name: Checkout packaging sources - uses: actions/checkout@v6 - - - name: Create unprivileged build user - run: | - useradd --create-home builder - install -Dm0440 /dev/stdin /etc/sudoers.d/builder <<'EOF' - builder ALL=(root) NOPASSWD: /usr/bin/pacman - EOF - - - name: Validate and publish package sources - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag }} - PKGREL: ${{ inputs.pkgrel || '1' }} - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - run: packaging/aur/scripts/release.sh diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 000000000..27ccf7a84 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,408 @@ +name: Release Build Jobs + +on: + workflow_call: + inputs: + channel: + description: Release channel to package + required: true + type: string + release_ref: + description: Commit to package + required: true + type: string + version: + description: Stable version override + required: false + default: "" + type: string + +permissions: + contents: read + +jobs: + metadata: + name: Resolve release metadata + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + channel: ${{ steps.release_meta.outputs.channel }} + version: ${{ steps.release_meta.outputs.version }} + tag: ${{ steps.release_meta.outputs.tag }} + release_name: ${{ steps.release_meta.outputs.name }} + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} + make_latest: ${{ steps.release_meta.outputs.make_latest }} + ref: ${{ inputs.release_ref }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.release_ref }} + fetch-depth: 0 + 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=@t3tools/scripts... + + - id: release_meta + name: Resolve version and channel + shell: bash + env: + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SHA: ${{ inputs.release_ref }} + RELEASE_RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + + package_version="$(node -p "require('./apps/server/package.json').version")" + channel="$RELEASE_CHANNEL" + echo "channel=$channel" >> "$GITHUB_OUTPUT" + + release_date="$(date -u +%Y%m%d)" + if [[ "$channel" == stable ]]; then + version="${RELEASE_VERSION#v}" + if [[ -z "$version" ]]; then + if [[ "$GITHUB_EVENT_NAME" == push ]]; then + version="$package_version" + else + version="$(node scripts/resolve-fork-stable-release.ts --date "$release_date" --version-only)" + fi + fi + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid stable release version: $version" >&2 + exit 1 + fi + + { + echo "version=$version" + echo "tag=v$version" + echo "name=T3 Code v$version" + echo "is_prerelease=false" + echo "make_latest=true" + } >> "$GITHUB_OUTPUT" + else + node scripts/resolve-nightly-release.ts \ + --channel "$channel" \ + --date "$release_date" \ + --run-number "$RELEASE_RUN_NUMBER" \ + --sha "$RELEASE_SHA" \ + --github-output + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + fi + + - id: previous_tag + name: Resolve previous release + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + + - name: Write release metadata + env: + VERSION: ${{ steps.release_meta.outputs.version }} + TAG: ${{ steps.release_meta.outputs.tag }} + RELEASE_NAME: ${{ steps.release_meta.outputs.name }} + PREVIOUS_TAG: ${{ steps.previous_tag.outputs.previous_tag }} + IS_PRERELEASE: ${{ steps.release_meta.outputs.is_prerelease }} + MAKE_LATEST: ${{ steps.release_meta.outputs.make_latest }} + RELEASE_REF: ${{ inputs.release_ref }} + RELEASE_CHANNEL: ${{ steps.release_meta.outputs.channel }} + run: | + mkdir -p release-metadata + jq -n \ + --arg version "$VERSION" \ + --arg tag "$TAG" \ + --arg name "$RELEASE_NAME" \ + --arg previousTag "$PREVIOUS_TAG" \ + --argjson prerelease "$IS_PRERELEASE" \ + --argjson makeLatest "$MAKE_LATEST" \ + --arg ref "$RELEASE_REF" \ + --arg channel "$RELEASE_CHANNEL" \ + '{version: $version, tag: $tag, name: $name, previousTag: $previousTag, prerelease: $prerelease, makeLatest: $makeLatest, ref: $ref, channel: $channel}' \ + > release-metadata/release.json + + - name: Upload release metadata + uses: actions/upload-artifact@v7 + with: + name: release-metadata + path: release-metadata/release.json + if-no-files-found: error + retention-days: 7 + + common_nix: + name: Build shared release files with Nix + needs: metadata + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.metadata.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + + - name: Restore and populate Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v14 + with: + use-flakehub: false + use-gha-cache: true + + - name: Build shared desktop and server files + env: + T3CODE_RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + run: | + release_source="$RUNNER_TEMP/t3code-release-source" + mkdir -p "$release_source" + git archive HEAD -- . ':(exclude).repos' | tar -x -C "$release_source" + + nix build \ + --impure \ + --out-link result \ + --file .github/nix/release-common.nix \ + --argstr repo "path:$release_source" \ + --argstr version "$T3CODE_RELEASE_VERSION" + + - name: Collect shared release files + run: | + mkdir -p release-common/apps/desktop release-common/apps/server release-common/wsl-prebuild + cp -R result/apps/desktop/dist-electron release-common/apps/desktop/ + cp -R result/apps/desktop/resources release-common/apps/desktop/ + cp -R result/apps/server/dist release-common/apps/server/ + cp result/wsl-prebuild/pty.node release-common/wsl-prebuild/ + test -f release-common/apps/desktop/dist-electron/preload.cjs + test -f release-common/apps/server/dist/bin.mjs + file release-common/wsl-prebuild/pty.node + + - name: Upload shared release files + uses: actions/upload-artifact@v7 + with: + name: release-common + path: release-common + if-no-files-found: error + retention-days: 1 + + desktop: + name: Package ${{ matrix.label }} + needs: + - metadata + - common_nix + runs-on: ${{ matrix.runner }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - label: macOS arm64 + runner: macos-15 + platform: mac + target: dmg + arch: arm64 + - label: macOS x64 + runner: macos-15-intel + platform: mac + target: dmg + arch: x64 + - label: Linux x64 + runner: ubuntu-latest + platform: linux + target: AppImage + arch: x64 + - label: Windows x64 + runner: windows-2025 + platform: win + target: nsis + arch: x64 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: "" + T3CODE_CLERK_JWT_TEMPLATE: "" + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "" + T3CODE_RELAY_URL: "" + T3CODE_DESKTOP_SKIP_BUILD: "true" + T3CODE_DESKTOP_SIGNED: "false" + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.metadata.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=@t3tools/desktop... + - --filter=t3... + - --filter=@t3tools/scripts... + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set release version + run: node scripts/update-release-package-versions.ts "${{ needs.metadata.outputs.version }}" + + - name: Download shared release files + uses: actions/download-artifact@v8 + with: + name: release-common + path: . + + - name: Install Windows native build prerequisites + 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 + } + + - name: Install Linux image tools + if: matrix.platform == 'linux' + 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 + + - name: Package desktop artifact + shell: bash + run: | + if [[ "${{ matrix.platform }}" == mac ]]; then + sudo launchctl limit maxfiles 65536 200000 + ulimit -n 65536 + fi + + args=( + --platform "${{ matrix.platform }}" + --target "${{ matrix.target }}" + --arch "${{ matrix.arch }}" + --build-version "${{ needs.metadata.outputs.version }}" + --verbose + ) + if [[ "${{ matrix.platform }}" == win ]]; then + args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") + fi + vp run dist:desktop:artifact "${args[@]}" + + - name: Collect release assets + shell: bash + run: | + 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 }}" == x64 ]]; then + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-x64.yml" + done + fi + + - name: Upload desktop artifacts + uses: actions/upload-artifact@v7 + with: + name: desktop-${{ matrix.platform }}-${{ matrix.arch }} + path: release-publish/* + if-no-files-found: error + retention-days: 7 + + web: + name: Package web + needs: metadata + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: "" + T3CODE_CLERK_JWT_TEMPLATE: "" + T3CODE_RELAY_URL: "" + VITE_HOSTED_STATIC_APP: "true" + VITE_HOSTED_APP_CHANNEL: ${{ needs.metadata.outputs.channel == 'canary' && 'canary' || contains(needs.metadata.outputs.version, '-nightly.') && 'nightly' || 'latest' }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.metadata.outputs.ref }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + - --filter=@t3tools/web... + + - name: Set release version + run: node scripts/update-release-package-versions.ts "${{ needs.metadata.outputs.version }}" + + - name: Build web + run: vp run --filter @t3tools/web build + + - name: Verify web asset paths + run: | + if grep -Eq '(src|href)="assets/' apps/web/dist/index.html; then + echo "Web assets must use root-relative paths." >&2 + exit 1 + fi + grep -Eq '(src|href)="/assets/' apps/web/dist/index.html + + - name: Archive web + run: | + mkdir -p release-publish + archive="$GITHUB_WORKSPACE/release-publish/T3-Code-Web-${{ needs.metadata.outputs.version }}.zip" + (cd apps/web && zip -r "$archive" dist) + unzip -t "$archive" + + - name: Upload web artifact + uses: actions/upload-artifact@v7 + with: + name: web-dist + path: release-publish/*.zip + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 000000000..7fb4cad7d --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,230 @@ +name: Stable Release PR + +on: + push: + branches: + - main + workflow_run: + workflows: + - Release Publish + - History validation + types: + - completed + branches: + - main + release: + types: + - published + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: stable-release-pr + cancel-in-progress: true + +jobs: + state: + name: Check stable release state + if: >- + (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && + (github.event_name != 'release' || github.event.release.prerelease == false) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + prepare: ${{ steps.release_state.outputs.prepare }} + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + + - name: Fetch tags + run: git fetch --force --tags origin + + - id: release_state + name: Check stable state and validated history + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + main_sha="$(git rev-parse HEAD)" + package_version="$(node -p "require('./apps/server/package.json').version")" + tag_sha="$(git rev-parse "refs/tags/v${package_version}^{commit}" 2>/dev/null || true)" + history_checks="$( + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/commits/$main_sha/check-runs?per_page=100" \ + | jq '[.[].check_runs[] | select(.name == "History / validate" and .conclusion == "success")] | length' + )" + if [[ -n "$tag_sha" && "$history_checks" -gt 0 ]]; then + echo "prepare=true" >> "$GITHUB_OUTPUT" + else + echo "prepare=false" >> "$GITHUB_OUTPUT" + fi + + prepare: + name: Prepare stable release draft + needs: state + if: needs.state.outputs.prepare == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + persist-credentials: true + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Fetch tags, upstream, and release branch + run: | + git fetch --force --tags origin + git fetch --no-tags origin upstream/main:refs/remotes/upstream/main + git fetch origin release/stable || true + + - id: existing + name: Preserve existing manual notes + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + number="$(gh pr list --head release/stable --base main --state open --json number --jq '.[0].number // empty')" + echo "number=$number" >> "$GITHUB_OUTPUT" + if [[ -n "$number" ]]; then + gh pr view "$number" --json body --jq .body > "$RUNNER_TEMP/release-pr-body.md" + else + : > "$RUNNER_TEMP/release-pr-body.md" + fi + + - id: release_meta + name: Resolve next stable version + run: | + node scripts/resolve-fork-stable-release.ts \ + --date "$(date -u +%Y%m%d)" \ + --github-output + + - id: update_versions + name: Update package versions + run: node scripts/update-release-package-versions.ts "${{ steps.release_meta.outputs.version }}" --github-output + + - id: previous_tag + name: Resolve previous stable release + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel stable \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + + - name: Refresh lockfile if manifests require it + if: steps.update_versions.outputs.changed == 'true' + run: vp install --lockfile-only --ignore-scripts + + - name: Reject dependency drift in release preparation + run: | + if ! git diff --quiet -- pnpm-lock.yaml; then + echo 'Release preparation found dependency lockfile changes.' >&2 + echo 'Actualize main first, then regenerate the stable draft.' >&2 + git diff -- pnpm-lock.yaml >&2 + exit 1 + fi + + - name: Render upstream changelog + run: | + node scripts/render-upstream-release-notes.mjs \ + --previous-tag "${{ steps.previous_tag.outputs.previous_tag }}" \ + --release-ref HEAD \ + --upstream-ref refs/remotes/upstream/main \ + --output "$RUNNER_TEMP/upstream-release-notes.md" + + - name: Write release branch + env: + RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} + run: | + set -euo pipefail + git config user.name "${{ steps.app_token.outputs.app-slug }}[bot]" + git config user.email "release-bot@noreply.github.com" + git switch -C release/stable + git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml + if git diff --cached --quiet; then + git commit --allow-empty -m "prepare stable release $RELEASE_VERSION" + else + git commit -m "prepare stable release $RELEASE_VERSION" + fi + git push --force-with-lease="refs/heads/release/stable" origin "HEAD:refs/heads/release/stable" + + - name: Compose draft body + env: + RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} + RELEASE_TAG: ${{ steps.release_meta.outputs.tag }} + PREVIOUS_TAG: ${{ steps.previous_tag.outputs.previous_tag }} + run: | + set -euo pipefail + manual_notes="$(sed -n '//,//{//!p;}' "$RUNNER_TEMP/release-pr-body.md")" + if [[ -z "$manual_notes" ]]; then + manual_notes='- Add fork-only changes here before promotion.' + fi + { + echo "## Stable release $RELEASE_VERSION" + echo + echo 'This draft is promoted with /promote; it must not be merged.' + echo + echo "Previous stable: ${PREVIOUS_TAG:-none}" + echo + cat "$RUNNER_TEMP/upstream-release-notes.md" + echo + echo '## Fork changes' + echo + echo '' + printf '%s\n' "$manual_notes" + echo '' + } > "$RUNNER_TEMP/release-pr-body-new.md" + + - name: Create or update draft release PR + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} + RELEASE_TAG: ${{ steps.release_meta.outputs.tag }} + EXISTING_PR: ${{ steps.existing.outputs.number }} + run: | + set -euo pipefail + if [[ -n "$EXISTING_PR" ]]; then + gh pr edit "$EXISTING_PR" \ + --title "prepare stable release $RELEASE_VERSION" \ + --body-file "$RUNNER_TEMP/release-pr-body-new.md" \ + --add-label release + gh pr ready "$EXISTING_PR" --undo || true + else + gh pr create \ + --base main \ + --head release/stable \ + --title "prepare stable release $RELEASE_VERSION" \ + --body-file "$RUNNER_TEMP/release-pr-body-new.md" \ + --label release \ + --draft + fi diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 000000000..065419471 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,236 @@ +name: Release Publish + +on: + workflow_run: + workflows: + - CI + - Prerelease Build + - Stable Release Build + types: [completed] + branches: + - main + - "canary/**" + +permissions: + actions: read + contents: write + pull-requests: read + +jobs: + runs: + name: Match successful CI and build runs + if: >- + github.event.workflow_run.conclusion == 'success' && + (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + ready: ${{ steps.match.outputs.ready }} + build_run_id: ${{ steps.match.outputs.build_run_id }} + steps: + - id: match + name: Match successful CI and build runs + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }} + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} + TRIGGER_WORKFLOW: ${{ github.event.workflow_run.name }} + run: | + set -euo pipefail + has_release_metadata() { + local run_id="$1" + gh api --method GET "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts" -f per_page=100 \ + --jq '[.artifacts[] | select(.name == "release-metadata" and .expired == false)] | length' \ + | grep -q '[1-9]' + } + + ci_run_id="$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs" \ + -f branch="$RELEASE_BRANCH" -f head_sha="$RELEASE_SHA" -f event=push -f status=success -f per_page=20 \ + --jq '.workflow_runs[0].id // empty')" + history_run_id="$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/workflows/history-validation.yml/runs" \ + -f branch="$RELEASE_BRANCH" -f head_sha="$RELEASE_SHA" -f event=push -f status=success -f per_page=20 \ + --jq '.workflow_runs[0].id // empty')" + build_run_id="" + case "$TRIGGER_WORKFLOW" in + "Prerelease Build" | "Stable Release Build") build_run_id="$TRIGGER_RUN_ID" ;; + CI) + for workflow in release.yml release-stable.yml; do + candidate="$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/workflows/$workflow/runs" \ + -f branch="$RELEASE_BRANCH" -f head_sha="$RELEASE_SHA" -f event=push -f status=success -f per_page=20 \ + --jq '.workflow_runs[0].id // empty')" + if [[ -n "$candidate" ]] && has_release_metadata "$candidate"; then + build_run_id="$candidate" + break + fi + done + ;; + *) echo "Unexpected release trigger: $TRIGGER_WORKFLOW" >&2; exit 1 ;; + esac + + if [[ -z "$ci_run_id" || -z "$history_run_id" || -z "$build_run_id" ]] || ! has_release_metadata "$build_run_id"; then + echo 'ready=false' >> "$GITHUB_OUTPUT" + else + echo 'ready=true' >> "$GITHUB_OUTPUT" + echo "build_run_id=$build_run_id" >> "$GITHUB_OUTPUT" + fi + + metadata: + name: Read release metadata + needs: runs + if: needs.runs.outputs.ready == 'true' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.read.outputs.version }} + tag: ${{ steps.read.outputs.tag }} + name: ${{ steps.read.outputs.name }} + previous_tag: ${{ steps.read.outputs.previous_tag }} + prerelease: ${{ steps.read.outputs.prerelease }} + ref: ${{ steps.read.outputs.ref }} + environment: ${{ steps.read.outputs.environment }} + channel: ${{ steps.read.outputs.channel }} + steps: + - name: Download release metadata + uses: actions/download-artifact@v8 + with: + name: release-metadata + path: release-metadata + github-token: ${{ github.token }} + run-id: ${{ needs.runs.outputs.build_run_id }} + + - id: read + name: Read metadata + run: | + metadata=release-metadata/release.json + { + echo "version=$(jq -r .version "$metadata")" + echo "tag=$(jq -r .tag "$metadata")" + echo "name=$(jq -r .name "$metadata")" + echo "previous_tag=$(jq -r .previousTag "$metadata")" + echo "prerelease=$(jq -r .prerelease "$metadata")" + echo "ref=$(jq -r .ref "$metadata")" + echo "channel=$(jq -r .channel "$metadata")" + if [[ "$(jq -r .prerelease "$metadata")" == true ]]; then + echo "environment=$(jq -r .channel "$metadata")" + else + echo 'environment=stable' + fi + } >> "$GITHUB_OUTPUT" + + publish: + name: Publish release + needs: [runs, metadata] + if: needs.runs.outputs.ready == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: ${{ needs.metadata.outputs.environment }} + steps: + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.metadata.outputs.ref }} + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Setup release notes tools + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + set -euo pipefail + git fetch --force --tags origin + upstream_ref=refs/remotes/origin/upstream/main + if [[ "$RELEASE_BRANCH" == canary/* ]]; then + upstream_branch="t3code/${RELEASE_BRANCH#canary/}" + upstream_ref="refs/remotes/upstream/$upstream_branch" + git fetch --no-tags https://github.com/pingdotgg/t3code.git \ + "$upstream_branch:$upstream_ref" + else + git fetch --no-tags origin upstream/main:refs/remotes/origin/upstream/main + fi + echo "RELEASE_UPSTREAM_REF=$upstream_ref" >> "$GITHUB_ENV" + + - name: Render release body + env: + GH_TOKEN: ${{ github.token }} + RELEASE_REF: ${{ needs.metadata.outputs.ref }} + PREVIOUS_TAG: ${{ needs.metadata.outputs.previous_tag }} + PRERELEASE: ${{ needs.metadata.outputs.prerelease }} + run: | + set -euo pipefail + if [[ "$PRERELEASE" == true ]]; then + if [[ -n "$PREVIOUS_TAG" ]]; then + node scripts/render-upstream-release-notes.mjs \ + --previous-tag "$PREVIOUS_TAG" \ + --release-ref "$RELEASE_REF" \ + --upstream-ref "$RELEASE_UPSTREAM_REF" \ + --output release-body.md + else + printf '%s\n' '## Upstream changes' '' '- No previous release tag was available.' > release-body.md + fi + else + pr_number="$(git show -s --format=%B "$RELEASE_REF" | sed -n 's/^Release-PR: #//p' | head -n 1)" + if [[ -n "$pr_number" ]]; then + gh pr view "$pr_number" --repo "$GITHUB_REPOSITORY" --json body --jq .body > release-body.md + else + node scripts/render-upstream-release-notes.mjs \ + --previous-tag "$PREVIOUS_TAG" \ + --release-ref "$RELEASE_REF" \ + --upstream-ref "$RELEASE_UPSTREAM_REF" \ + --output release-body.md + fi + fi + + - name: Download desktop artifacts + uses: actions/download-artifact@v8 + with: + pattern: desktop-* + merge-multiple: true + path: release-assets + github-token: ${{ github.token }} + run-id: ${{ needs.runs.outputs.build_run_id }} + + - name: Download web artifact + uses: actions/download-artifact@v8 + with: + name: web-dist + path: release-assets + github-token: ${{ github.token }} + run-id: ${{ needs.runs.outputs.build_run_id }} + + - name: Merge macOS updater manifests + run: | + shopt -s nullglob + for x64_manifest in release-assets/*-mac-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}.yml" + test -f "$arm64_manifest" + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" + rm "$x64_manifest" + done + + - name: Publish release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.metadata.outputs.tag }} + target_commitish: ${{ needs.metadata.outputs.ref }} + name: ${{ needs.metadata.outputs.name }} + body_path: release-body.md + prerelease: ${{ needs.metadata.outputs.prerelease }} + make_latest: ${{ needs.metadata.outputs.prerelease == 'false' }} + files: | + release-assets/*.dmg + release-assets/*.zip + release-assets/*.AppImage + release-assets/*.exe + release-assets/*.blockmap + release-assets/*.yml + fail_on_unmatched_files: true diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 000000000..7b2acba7d --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,19 @@ +name: Stable Release Build + +on: + push: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + build: + name: Package stable release + if: contains(github.event.head_commit.message, 'prepare stable release ') + uses: ./.github/workflows/release-build.yml + with: + channel: stable + release_ref: ${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f9bd341d..826d1afb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,1250 +1,35 @@ -name: Release +name: Prerelease Build on: push: - tags: - - "v*.*.*" - - "!v*-nightly.*" - schedule: - # Avoid minute zero, when GitHub scheduled jobs are busiest. - - cron: "8,38 * * * *" + branches: + - main workflow_dispatch: inputs: channel: - description: "Release channel" - required: false - default: stable + description: "Prerelease channel" + required: true + default: nightly type: choice options: - - stable - nightly - version: - description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." - required: false - type: string - -# Serialize nightlies (scheduled and manual) so overlapping runs cannot build -# the same commit twice or publish out of order. Stable tag releases get their -# own group so a nightly never blocks them. Running publishers are never -# canceled, and queue: max keeps every pending run instead of the default -# 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' }} - cancel-in-progress: false - queue: max + - canary permissions: contents: read - id-token: none - -jobs: - # Picks the commit every later job builds. Nightlies and tag pushes build the - # triggering commit. Manual stable releases build the commit of the latest - # published nightly, so stable only ever ships a build that nightly users - # have already run. Scheduled runs also decide here whether a nightly is due. - resolve_commit: - name: Resolve release commit - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 5 - outputs: - ref: ${{ steps.resolve.outputs.ref }} - nightly_version: ${{ steps.resolve.outputs.nightly_version }} - has_changes: ${{ steps.resolve.outputs.has_changes }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: .github/scripts - - - id: resolve - name: Resolve release commit - uses: actions/github-script@v8 - env: - DISPATCH_CHANNEL: ${{ inputs.channel }} - with: - script: | - const { - shouldReleaseNightly, - resolveLatestNightlyCommit, - } = require('./.github/scripts/check-nightly-release.cjs'); - - 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') { - const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); - core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); - core.setOutput('ref', sha); - core.setOutput('nightly_version', version); - } else { - core.setOutput('ref', context.sha); - } - - preflight: - name: Preflight - 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: 10 - outputs: - release_channel: ${{ steps.release_meta.outputs.release_channel }} - version: ${{ steps.release_meta.outputs.version }} - tag: ${{ steps.release_meta.outputs.tag }} - release_name: ${{ steps.release_meta.outputs.name }} - short_sha: ${{ steps.release_meta.outputs.short_sha }} - previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} - cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} - is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} - make_latest: ${{ steps.release_meta.outputs.make_latest }} - ref: ${{ needs.resolve_commit.outputs.ref }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.resolve_commit.outputs.ref }} - fetch-depth: 0 - 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: true - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - - - id: release_meta - name: Resolve release version - shell: bash - env: - DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} - DISPATCH_VERSION: ${{ github.event.inputs.version }} - NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }} - NIGHTLY_DATE: ${{ github.run_started_at }} - NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} - NIGHTLY_RUN_NUMBER: ${{ github.run_number }} - run: | - if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then - nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" - - node scripts/resolve-nightly-release.ts \ - --date "$nightly_date" \ - --run-number "$NIGHTLY_RUN_NUMBER" \ - --sha "$NIGHTLY_SHA" \ - --github-output - - echo "release_channel=nightly" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - else - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}" - if [[ -z "$raw" ]]; then - echo "workflow_dispatch stable releases need a version input or a published nightly." >&2 - exit 1 - fi - else - raw="${GITHUB_REF_NAME}" - fi - - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi - - echo "release_channel=stable" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - fi - fi - - - id: previous_tag - name: Resolve previous release tag - run: | - node scripts/resolve-previous-release-tag.ts \ - --channel "${{ steps.release_meta.outputs.release_channel }}" \ - --current-tag "${{ steps.release_meta.outputs.tag }}" \ - --github-output - - # Share only the verification results, not the large registry metadata cache. - - name: Upload dependency verification - continue-on-error: true - uses: actions/upload-artifact@v7 - with: - name: release-dependency-verification - path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl - - quality: - name: Release quality checks - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.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: true - - - name: Ensure Electron runtime is installed - run: vp run --filter @t3tools/desktop ensure:electron - - - name: Check - run: vp check - - - name: Typecheck - run: vp run typecheck - - - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - - name: Test - run: vp run test - - relay_public_config: - name: Resolve T3 Connect public config - # Consumes only the release commit, not preflight's resolved version, so it - # runs alongside preflight instead of after it. The condition mirrors preflight's. - 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: 5 - environment: - name: production - outputs: - clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} - clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} - clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} - relay_url: ${{ steps.public_config.outputs.relay_url }} - env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} - RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} - CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} - CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} - 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=t3code-relay... - - - id: relay_state - name: Read production relay tracing config - shell: bash - run: | - vp run --filter t3code-relay deploy \ - --stage prod \ - --read-state \ - --github-output \ - --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" - - - name: Upload relay client tracing config - uses: actions/upload-artifact@v7 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing.env - if-no-files-found: error - retention-days: 1 - - - id: public_config - name: Resolve production relay public config - shell: bash - run: | - set -euo pipefail - - relay_domain="${RELAY_DOMAIN:-}" - if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then - relay_domain="relay.$RELAY_API_ZONE_NAME" - fi - required=( - relay_domain - CLERK_PUBLISHABLE_KEY - CLERK_JWT_TEMPLATE - CLERK_CLI_OAUTH_CLIENT_ID - ) - missing=() - for name in "${required[@]}"; do - if [[ -z "${!name:-}" ]]; then - missing+=("$name") - fi - done - if (( ${#missing[@]} > 0 )); then - printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2 - exit 1 - fi - - echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" - echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" - echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" - echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" + id-token: write - # 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) - # 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 +concurrency: + group: prerelease-build-${{ github.ref }}-${{ github.event_name == 'push' && 'push' || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' }} +jobs: 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] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: ${{ matrix.runner }} - 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 - with: - ref: ${{ needs.preflight.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: ${{ matrix.platform != 'win' }} - 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 - 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/${{ 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: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.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 "${{ 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' }} - 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" - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* - if-no-files-found: error - - - name: Upload resource monitor - uses: actions/upload-artifact@v7 - with: - name: resource-monitor-${{ matrix.resource_key }} - path: resource-monitor-publish/${{ matrix.resource_key }}/* - if-no-files-found: error - - publish_cli: - name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - 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 - with: - ref: ${{ needs.preflight.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... - - --filter=@t3tools/web... - - --filter=@t3tools/scripts... - - - 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 "${{ needs.preflight.outputs.version }}" - - # 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: Download resource monitors - uses: actions/download-artifact@v8 - with: - pattern: resource-monitor-* - path: ${{ runner.temp }}/resource-monitors - - - name: Bundle resource monitors into CLI package - shell: bash - run: | - set -euo pipefail - for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do - resource_key="${artifact_dir##*/resource-monitor-}" - target_dir="apps/server/dist/resource-monitor/${resource_key}" - mkdir -p "$target_dir" - cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" - chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true - done - - - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose - - 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' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 30 - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.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=@t3tools/scripts... - - - name: Download all desktop artifacts - uses: actions/download-artifact@v8 - with: - pattern: desktop-* - merge-multiple: true - path: release-assets - - - name: Merge macOS updater manifests - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" - rm -f "$x64_manifest" - fi - done - - - name: Publish release - if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ needs.preflight.outputs.tag }} - target_commitish: ${{ needs.preflight.outputs.ref }} - name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true - previous_tag: ${{ needs.preflight.outputs.previous_tag }} - prerelease: ${{ needs.preflight.outputs.is_prerelease }} - make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml - fail_on_unmatched_files: true - token: ${{ github.token }} - - - name: Publish first release - if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ needs.preflight.outputs.tag }} - target_commitish: ${{ needs.preflight.outputs.ref }} - name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true - prerelease: ${{ needs.preflight.outputs.is_prerelease }} - make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml - fail_on_unmatched_files: true - token: ${{ github.token }} - - publish_aur: - name: Publish AUR package - needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} - uses: ./.github/workflows/publish-aur.yml + name: Package prerelease + if: >- + github.event_name == 'workflow_dispatch' || + !contains(github.event.head_commit.message, 'prepare stable release ') + uses: ./.github/workflows/release-build.yml with: - release_tag: ${{ needs.preflight.outputs.tag }} - secrets: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - - 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' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - 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 }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} - T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} - T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} - VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - 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=@t3tools/scripts... - - --filter=@t3tools/web... - - - 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 "${{ needs.preflight.outputs.version }}" - - - name: Refresh release lockfile - run: vp install --lockfile-only --ignore-scripts - - - name: Deploy and alias channel - shell: bash - run: | - set -euo pipefail - - if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then - echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 - exit 1 - fi - - router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" - latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}" - nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}" - router_domain="${router_url#http://}" - router_domain="${router_domain#https://}" - router_domain="${router_domain%%/*}" - - if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then - channel_domain="$latest_domain" - channel_name="latest" - else - channel_domain="$nightly_domain" - channel_name="nightly" - fi - - vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - vercel_scope_args=(--scope "$vercel_scope") - - echo "Deploying hosted web app for $channel_name channel." - deployment_url="$( - vp dlx vercel@53.1.1 deploy \ - --archive=tgz \ - --prod \ - --skip-domain \ - --yes \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" \ - --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ - --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ - --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ - --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ - --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ - --build-env "VITE_HOSTED_APP_URL=$router_url" \ - --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" - )" - - echo "Aliasing $deployment_url to $channel_domain." - vp dlx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" - - if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then - echo "Aliasing $deployment_url to router domain $router_domain." - vp dlx vercel@53.1.1 alias set "$deployment_url" "$router_domain" \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" - fi - - deploy_marketing: - name: Deploy marketing site - needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.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=@t3tools/marketing... - - - name: Deploy marketing site to Vercel - shell: bash - run: | - set -euo pipefail - - if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" ]]; then - echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID." >&2 - exit 1 - fi - - VERCEL_PROJECT_ID="$( - curl --fail --silent --show-error \ - --header "Authorization: Bearer $VERCEL_TOKEN" \ - "https://api.vercel.com/v9/projects/t3code-marketing?teamId=$VERCEL_ORG_ID" \ - | jq --exit-status --raw-output '.id' - )" - export VERCEL_PROJECT_ID - - vp dlx vercel@53.1.1 deploy \ - --archive=tgz \ - --prod \ - --yes \ - --token "$VERCEL_TOKEN" \ - --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - - finalize: - name: Finalize release - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} - needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout - uses: actions/checkout@v6 - with: - ref: main - fetch-depth: 0 - token: ${{ steps.app_token.outputs.token }} - persist-credentials: true - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - id: app_bot - name: Resolve GitHub App bot identity - env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} - APP_SLUG: ${{ steps.app_token.outputs.app-slug }} - run: | - user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" - echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" - echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... - - - id: update_versions - name: Update version strings - env: - RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - - - name: Format package.json files - if: steps.update_versions.outputs.changed == 'true' - run: vp fmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json - - - name: Refresh lockfile - if: steps.update_versions.outputs.changed == 'true' - run: vp install --lockfile-only --ignore-scripts - - - name: Commit and push version bump - if: steps.update_versions.outputs.changed == 'true' - shell: bash - env: - RELEASE_TAG: ${{ needs.preflight.outputs.tag }} - run: | - if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml; then - echo "No version changes to commit." - exit 0 - fi - - git config user.name "${{ steps.app_bot.outputs.name }}" - git config user.email "${{ steps.app_bot.outputs.email }}" - - git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml - git commit -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main - - announce_discord: - name: Announce release on Discord - if: | - always() && !cancelled() && - needs.preflight.result == 'success' && - needs.relay_public_config.result == 'success' && - needs.release.result == 'success' && - needs.deploy_web.result == 'success' && - (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') - needs: [preflight, relay_public_config, release, deploy_web, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - 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=@t3tools/scripts... - - - name: Announce prerelease on Discord - if: needs.preflight.outputs.is_prerelease == 'true' - continue-on-error: true - env: - DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} - run: | - node scripts/notify-discord-release.ts prerelease \ - --role-id "$DISCORD_MENTION_ROLE_ID" \ - --release-name "${{ needs.preflight.outputs.release_name }}" \ - --release-version "${{ needs.preflight.outputs.version }}" \ - --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" - - - name: Announce latest release on Discord - if: needs.preflight.outputs.make_latest == 'true' - continue-on-error: true - env: - DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} - run: | - node scripts/notify-discord-release.ts latest \ - --role-id "$DISCORD_MENTION_ROLE_ID" \ - --release-name "${{ needs.preflight.outputs.release_name }}" \ - --release-version "${{ needs.preflight.outputs.version }}" \ - --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" + channel: ${{ github.event_name == 'workflow_dispatch' && inputs.channel || 'nightly' }} + release_ref: ${{ github.sha }} diff --git a/.github/workflows/sync-upstream-main.yml b/.github/workflows/sync-upstream-main.yml new file mode 100644 index 000000000..ce1c5f177 --- /dev/null +++ b/.github/workflows/sync-upstream-main.yml @@ -0,0 +1,134 @@ +name: Sync upstream main + +on: + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + inputs: + target_sha: + description: "Optional upstream main commit to mirror" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: sync-upstream-main + cancel-in-progress: true + +jobs: + sync: + name: Sync upstream main mirror + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + + - id: gate + name: Keep the mirror stable during actualization + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + open_prs="$(gh pr list --repo "$GITHUB_REPOSITORY" --base upstream/main --state open --label actualization --json number --jq length)" + echo "has_open_actualization=$([[ "$open_prs" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + if: steps.gate.outputs.has_open_actualization == 'false' + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + + - name: Import upstream main objects + id: upstream + if: steps.gate.outputs.has_open_actualization == 'false' + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + REQUESTED_SHA: ${{ inputs.target_sha }} + run: | + set -euo pipefail + git fetch --no-tags https://github.com/pingdotgg/t3code.git main:refs/remotes/upstream/main + git fetch --no-tags origin main:refs/remotes/origin/main upstream/main:refs/remotes/origin/upstream/main + if [[ -n "$REQUESTED_SHA" ]]; then + if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "target_sha must be a full commit SHA" >&2 + exit 1 + fi + upstream_sha="$(git rev-parse "$REQUESTED_SHA^{commit}")" + if ! git merge-base --is-ancestor "$upstream_sha" refs/remotes/upstream/main; then + echo "target_sha is not reachable from upstream main" >&2 + exit 1 + fi + else + upstream_sha="$(git rev-parse refs/remotes/upstream/main)" + fi + current_sha="$(git rev-parse refs/remotes/origin/upstream/main 2>/dev/null || true)" + main_sha="$(git rev-parse refs/remotes/origin/main)" + echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT" + echo "current_sha=$current_sha" >> "$GITHUB_OUTPUT" + echo "main_sha=$main_sha" >> "$GITHUB_OUTPUT" + + - name: Update upstream main mirror + if: >- + steps.gate.outputs.has_open_actualization == 'false' && + steps.upstream.outputs.upstream_sha != steps.upstream.outputs.current_sha + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + UPSTREAM_SHA: ${{ steps.upstream.outputs.upstream_sha }} + CURRENT_SHA: ${{ steps.upstream.outputs.current_sha }} + run: | + set -euo pipefail + import_ref="upstream/import-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cleanup() { + git push origin ":refs/heads/$import_ref" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + git push origin "refs/remotes/upstream/main:refs/heads/$import_ref" + remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/upstream/main" --jq .object.sha)" + if [[ "$remote_sha" != "$CURRENT_SHA" ]]; then + echo "upstream/main moved from $CURRENT_SHA to $remote_sha; retry the sync" >&2 + exit 1 + fi + gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/heads/upstream/main" \ + -f sha="$UPSTREAM_SHA" -F force=true >/dev/null + + - name: Prepare conflict PR head + if: steps.upstream.outputs.upstream_sha != steps.upstream.outputs.current_sha + run: git push --force origin "refs/remotes/origin/main:refs/heads/actualization/incoming" + + - name: Create actualization PR + if: steps.upstream.outputs.upstream_sha != steps.upstream.outputs.current_sha + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + MAIN_SHA: ${{ steps.upstream.outputs.main_sha }} + UPSTREAM_SHA: ${{ steps.upstream.outputs.upstream_sha }} + run: | + set -euo pipefail + existing="$(gh pr list --repo "$GITHUB_REPOSITORY" --head actualization/incoming --base upstream/main --state open --json number --jq '.[0].number // empty')" + if [[ -n "$existing" ]]; then + gh pr edit "$existing" \ + --title 'actualize fork on current upstream' \ + --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. This PR intentionally starts with conflicts; rebuild its head manually before /promote.\n\n' + else + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --base upstream/main \ + --head actualization/incoming \ + --title 'actualize fork on current upstream' \ + --label actualization \ + --body $'Actualizes the maintained history on upstream/main at '"${UPSTREAM_SHA}"$'. This PR intentionally starts with conflicts; rebuild its head manually before /promote.\n\n' + fi diff --git a/AGENTS.md b/AGENTS.md index e3b5771d7..5c8eb8ce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # T3 Code +Fork maintenance routing: when the task is actualization, backport, feature/fix delivery, or release, read [MAINTENANCE.md](./MAINTENANCE.md) before changing history, branches, or release state. + T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000..96bd23ead --- /dev/null +++ b/FORK.md @@ -0,0 +1,36 @@ +# T3 Code fork + +This repository is the `tarik02-org/t3code` fork of `pingdotgg/t3code`. + +`main` is the canonical fork history. It is linear and based on the protected `upstream/main` mirror. +`Sync upstream main` imports upstream objects and opens an intentional-conflict copy of `main` against `upstream/main`; the manually rebased head is promoted back into `main` by `/promote`. + +Independent experiments use a manually maintained `canary/` branch (for example `canary/codex-turn-mapping`) against a branch fetched from the local `upstream` remote. They have no fork-side `upstream/*` mirror and never participate in main actualization or promotion. + +Read [MAINTENANCE.md](./MAINTENANCE.md) before actualizing, backporting, delivering fork changes, or releasing. + +## Ownership + +| Area | Owner | Rule | +| ----------------------------------------------------- | -------- | ---------------------------------------------------------------------------- | +| `.github/workflows/` | fork | Port useful upstream automation deliberately. Do not import it mechanically. | +| `AGENTS.md`, `FORK.md`, `MAINTENANCE.md`, `README.md` | fork | Keep the agent contract and fork workflow current. | +| `.github/nix/`, `flake.nix`, `nix/` | fork | Keep packaging and offline dependency inputs buildable. | +| `docs/` | upstream | Do not spend fork maintenance on upstream documentation. | +| Application and shared code | shared | Keep the fork delta narrow and upstream-compatible. | + +## Compatibility + +- Upstream clients can use the fork server without fork-specific assumptions. +- Existing upstream RPC contracts remain compatible. +- Fork-only RPCs are optional and advertised before clients use them. +- Fork-only durable data lives in sidecar storage. +- Thread history uses upstream bounded snapshots and turn-window APIs. + +## Fork-owned behavior + +- Incremental thread-shell projections and bounded command-output reads. +- Thread goals stored in a sidecar database. +- Frontmatter rendering in web and mobile previews. +- Desktop backendless mode, unsigned macOS updates, and fork packaging. +- Thread-scoped launch environment identity for providers and terminals. diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 000000000..443560ec2 --- /dev/null +++ b/MAINTENANCE.md @@ -0,0 +1,123 @@ +# Fork maintenance + +This is the operating runbook for `tarik02-org/t3code`. + +Use it as routing: + +- `actualize`: rebuild the fork stack on the current `upstream/main`. +- `feature` or `fix`: deliver one fork change through a squash PR. +- `backport`: bring a selected upstream change into the fork, then deliver it as one fork commit. +- `release`: update the stable draft, promote its release commit, and wait for stable approval. + +## History contract + +The canonical `main` history has these strata, in order: + +```text +upstream/main +fork CI and workflow replacement +fork packaging infrastructure +fork feature and fix commits +one mutable release-state commit +``` + +`upstream/main` is a protected mirror. The sync workflow imports upstream objects, updates the mirror, snapshots current `main` into `actualization/incoming`, and opens a Draft PR against `upstream/main`. The mirror is left unchanged while that PR is open. + +That PR is intentionally stale and normally conflicted: its first head is the current `main`, not a rebased result. Manual work rebuilds the head on the new mirror. Promotion then force-replaces `main` with the reviewed head. + +The release-state commit contains package versions and any final generated lock/hash state. It is replaced during release preparation. Dependency declarations stay with the feature or fix that needs them. Intermediate lockfiles and Nix hashes are consolidated before release. + +History above the upstream base is linear. `history/validated` must pass before a stable release can be promoted. Release tags preserve published chronology; no extra backup branch is required for normal work. + +## Feature and fix delivery + +1. Start from the current `org/main`. +2. Make one logical change. Keep all clients, contracts, providers, and connection modes in scope when they apply. +3. Keep the branch buildable. If dependencies change, update the lockfile and Nix hash for the branch so CI can build it offline. +4. Open a PR to `main` and squash it into one durable commit. +5. After the squash lands, run `actualize` before the next stable release. A feature integrated after release-state makes `main` temporarily unvalidated. + +If `main` is rewritten while a feature PR is open, rebuild the branch from the new `main`. Do not carry the old ancestry forward. + +## Backporting upstream + +1. Start from current `org/main`. +2. Identify the upstream commit or PR and check whether the change is already in the current upstream base. +3. Apply and adapt only the requested behavior. Preserve the upstream reference in the commit body. +4. Run focused checks for the touched clients, providers, contracts, and server seams. +5. Squash the result into a PR to `main`. +6. Run `actualize` after integration. + +Drop a backport when the behavior is already in upstream or no longer fits the current architecture. Do not resurrect removed fork architecture just to replay an old commit. + +## Actualization + +Actualization is a local rebuild followed by a Draft PR promoted into `main`. + +1. Run the scheduled or manual `Sync upstream main` workflow, then fetch `upstream/main` and `org/main`. +2. Record the old upstream base and the current fork-only delta. +3. Rebuild a temporary `actualize/` branch from the new `upstream/main`, replaying the fork strata in order. +4. Resolve conflicts by current intent: + - keep fork workflows and packaging; + - keep behavior still required by the fork; + - drop behavior now supplied by upstream; + - port provider, orchestration, projection, composer, sidebar, and terminal changes to current seams; + - leave upstream documentation upstream. +5. Remove the old release-state commit. Consolidate generated lockfile and Nix hash changes, then add one release-state commit with the last published stable version. +6. Run `range-diff`, the full fork delta review, focused checks for every conflict area, `history/validated`, and the Nix runtime build. +7. Push the temporary branch. +8. The sync workflow opens a Draft `actualization/incoming -> upstream/main` PR with intentional conflicts because its head starts as current `main`. +9. Rebuild that PR head manually on the current `upstream/main`, preserving the seven strata, then resolve the conflicts and push the head. +10. After checks pass, comment `/promote`. Promotion validates the candidate against `upstream/main`, force-updates `main`, closes the PR, and deletes the temporary head. + +If `main` or `upstream/main` moves before promotion, refresh the actualization. Promotion uses a lease and refuses a stale base. + +## Stable release + +The bot maintains a Draft `release/stable` PR only after `main` passes `history/validated` and the current package version has a stable tag. + +The bot updates the date-based version and the four package manifests. It keeps the manual fork changelog section between its markers and refreshes only the generated upstream section. Dependency drift belongs in actualization, not in this PR. + +The release PR is applied only through `/promote`: + +1. The workflow verifies that `main` is still the PR base and that its history is validated. +2. It takes the release PR tree and creates a new release-state commit with the parent of the old release-state commit. +3. It force-updates `main` with that replacement commit and closes the PR. +4. The stable build waits for CI and the matching build for that exact SHA. +5. The `stable` GitHub Environment requires `tarik02` approval before publication. + +If the build fails, rerun it on the same SHA. No release exists until the publish job succeeds. A newly published stable release causes the bot to refresh the next Draft release PR. + +## Nightly releases + +Every push to `main` starts the nightly build. A stable preparation commit is excluded from nightly packaging. + +Nightly notes compare the previous channel tag and the upstream bases of the two release commits. Fork-only commits are omitted. Stable notes include the generated upstream section plus the manual fork section from the release PR. + +Release publication requires successful CI, successful `history/validated`, and a successful matching build for the same SHA. + +## Canary release trees + +Canary trees are independent, manual histories. Keep the upstream experiment only in the local `upstream` remote and keep the canary tree as a fork branch when it needs to be built: + +```text +canary/codex-turn-mapping +``` + +Fetch `t3code/codex-turn-mapping` directly from the local `upstream` remote when rebuilding. There is no fork-side `upstream/codex-turn-mapping` mirror and no canary PR flow. Rebuild and promote the canary branch manually when its upstream base or patch stack changes. + +Pushes to `canary/*` run CI and history validation against the matching upstream branch fetched directly from `pingdotgg/t3code`. A manually dispatched release build can package that branch with `channel=canary`; it publishes a separate prerelease tag, web channel, desktop updater channel, and isolated desktop data directory. Canary promotion is only a deliberate force-push of the reviewed canary ref; it never changes `main`. + +## Promotion rules + +- `/promote` is accepted only from repository members, collaborators, or the owner. +- `actualization` replaces `main` with the exact reviewed PR head. +- `release` replaces only the old release-state commit with the reviewed release tree. +- A stale base, failed check, non-linear history, unexpected release-state file, or mismatched package version blocks promotion. +- The GitHub App bypasses the `main` non-fast-forward rule. Human stable approval remains a separate Environment gate. + +## Completion + +An actualization is complete when its PR is closed by promotion, `main` points at the reviewed SHA, `history/validated` passes, and the temporary branch is gone. + +A release is complete when the stable Environment job publishes the tag and assets, the release body contains the upstream and manual sections, and the next Draft release PR reflects the new stable tag. diff --git a/README.md b/README.md index 27b5dc491..68ed0b2a7 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,50 @@ # T3 Code -T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). +T3 Code is a local GUI for Codex, Claude Code, Cursor, Grok Build, OpenCode, and Antigravity. It runs provider agents on your machine and lets web, desktop, and mobile clients control them. -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. +This repository is the `tarik02-org` fork of [T3 Code](https://github.com/pingdotgg/t3code). It stays compatible with upstream clients and data while carrying a small set of product changes and its own release pipeline. See [FORK.md](./FORK.md) for the maintained behavior and ownership rules. -## "Wait, what are you selling me?" +## Fork goals -Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar. +- Keep long conversations fast without transferring or rendering the whole thread on every update. +- Prefer small, self-contained changes that can be replayed on current upstream code. +- Base stable releases on explicit upstream stable commits. +- Publish first-party Nix, desktop, and web artifacts. -We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the editor that you want. +## Compatibility -## Installation - -> [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Antigravity. Install and authenticate at least one provider before use: -> -> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` -> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` -> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` -> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` -> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` -> - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. - -### Try it out (install-free) - -The easiest way to test T3 Code is to run the server in your terminal (requires Node.js 22.16+, 23.11+, or 24.10+): - -```bash -npx t3@latest -``` - -This will launch T3 Code's backend on your machine as well as the local web app to control your agents. - -Tip: Use `npx t3@latest --help` for the full CLI reference. - -### Desktop app +- Upstream clients can use the fork server, and fork clients can use upstream servers. +- Existing upstream RPC contracts remain compatible. +- Fork-only RPCs are optional and advertised before clients use them. +- Fork-only durable data uses separate sidecar storage. -Install the latest version of the desktop app from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or from your favorite package registry: +## Maintained changes -#### Windows (`winget`) - -```bash -winget install T3Tools.T3Code -``` - -#### macOS (Homebrew) - -```bash -brew install --cask t3-code -``` - -#### Arch Linux (AUR) - -Stable: - -```bash -yay -S t3code-bin -``` +- Incremental thread-shell projections and bounded command-output reads. +- Provider-backed thread goals stored in a sidecar database. +- Frontmatter rendering in web and mobile previews. +- Desktop backendless mode and unsigned macOS updates. +- Thread-scoped launch environment identity for providers and terminals. +- Nix packaging and stable, nightly, and manually managed canary releases. -Nightly: - -```bash -yay -S t3code-nightly-bin -``` - -The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). - -## Some notes - -We are very very early in this project. Expect bugs. - -We are (mostly) not accepting contributions yet. Small fixes may be considered. Big features will not be. - -## Documentation - -Full docs live in [docs/](./docs). There's no docs site yet. - -- [Install and first run](./docs/user/install.md) -- [Permission modes](./docs/user/permission-modes.md) -- [Keyboard shortcuts](./docs/user/keybindings.md) -- [Project settings](./docs/user/project-settings.md) -- [Remote access from a phone or another machine](./docs/user/remote-access.md) -- [Keeping app and server in sync](./docs/user/updating.md) -- [Source control integrations](./docs/user/source-control.md) -- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) -- [Run T3 Code as a background service](./docs/user/background-service.md) - -Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). - -## If you REALLY want to contribute still.... read this first - -### Install `vp` +## Installation -T3 Code uses Vite+ so you'll need to install the global `vp` command-line tool. +Install and authenticate at least one supported provider before starting T3 Code. -#### macOS / Linux +### Release artifacts -```bash -curl -fsSL https://vite.plus | bash -``` +Desktop builds for macOS, Linux, and Windows, plus hosted web archives, are available from [GitHub Releases](https://github.com/tarik02-org/t3code/releases). Desktop builds are unsigned, so the operating system may ask you to approve them on first launch. -#### Windows +### Nix -```bash -irm https://vite.plus/ps1 | iex -``` +The Nix flake currently supports `x86_64-linux` and exposes `t3code-desktop` and `t3code-headless` packages. -Checkout their getting started guide for more information: https://viteplus.dev/guide/ +## Development -### Install dependencies +Install [Vite+](https://viteplus.dev/guide/), then install the workspace dependencies: -```bash +```console vp i ``` -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting a bug or opening a PR. - -Have a feature request? Start an [Ideas discussion](https://github.com/pingdotgg/t3code/discussions/categories/ideas). - -Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). +Read [MAINTENANCE.md](./MAINTENANCE.md) before changing fork history or release state. Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or pull request. diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 38732e429..8a2536bfb 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -95,7 +95,8 @@ it("reports a newer installed service and gives an exact-version repair command" assert.notInclude(output, "npx t3@latest service update"); }); -const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; +const newerServiceVersion = `${Number.parseInt(packageJson.version, 10) + 1}.0.0`; +const newerServiceStatus = { ...status, current: false, installedVersion: newerServiceVersion }; function makeTestService(serviceStatus: BootService.BootServiceStatus) { const installOptions: Array[0]> = []; @@ -141,7 +142,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands expect(error).toMatchObject({ _tag: "BootServiceDowngradeRefusedError", - installedVersion: "999.0.0", + installedVersion: newerServiceVersion, targetVersion: packageJson.version, }); expect(installOptions).toEqual([]); diff --git a/apps/server/src/cloud/cliAuthHtml.ts b/apps/server/src/cloud/cliAuthHtml.ts index 69d3b471a..1da3c7608 100644 --- a/apps/server/src/cloud/cliAuthHtml.ts +++ b/apps/server/src/cloud/cliAuthHtml.ts @@ -1,12 +1,13 @@ -export type LoopbackAuthorizationStage = "dev" | "nightly" | "latest"; +export type LoopbackAuthorizationStage = "canary" | "dev" | "nightly" | "latest"; -declare const __T3CODE_BUILD_CHANNEL__: "nightly" | "latest" | undefined; +declare const __T3CODE_BUILD_CHANNEL__: "canary" | "nightly" | "latest" | undefined; function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { return typeof __T3CODE_BUILD_CHANNEL__ === "undefined" ? "dev" : __T3CODE_BUILD_CHANNEL__; } const stageBrands = { + canary: "T3 Code (Canary)", dev: "T3 Code (Dev)", nightly: "T3 Code (Nightly)", latest: "T3 Code", @@ -62,10 +63,12 @@ export function renderLoopbackAuthorizationCompleteHtml( radial-gradient(circle at 76% 18%, rgba(136, 204, 255, 0.52), transparent 38%), linear-gradient(135deg, #2468df, #172f82); } - .stage-dev { + .stage-dev, + .stage-canary { background: linear-gradient(145deg, #5ab8fa 0%, #347ff8 46%, #1939bd 100%); } - .stage-dev::before { + .stage-dev::before, + .stage-canary::before { content: ""; position: absolute; inset: 0; diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 88c8c2f4d..107e141b5 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -21,7 +21,11 @@ import { export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); -const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; +const cliBuildChannel = packageJson.version.includes("-canary.") + ? "canary" + : packageJson.version.includes("-nightly.") + ? "nightly" + : "latest"; export default mergeConfig( baseConfig, diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts index 12a823a36..a31968d54 100644 --- a/apps/web/vercel.ts +++ b/apps/web/vercel.ts @@ -4,6 +4,7 @@ const ROUTER_HOST = "app.t3.codes"; const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel"; const LATEST_ORIGIN = "https://latest.app.t3.codes"; const NIGHTLY_ORIGIN = "https://nightly.app.t3.codes"; +const CANARY_ORIGIN = "https://canary.app.t3.codes"; const CLEAN_CHANNEL_QUERY_TRANSFORMS = [ { type: "request.query", @@ -12,7 +13,7 @@ const CLEAN_CHANNEL_QUERY_TRANSFORMS = [ }, ] satisfies Transform[]; -function channelCookie(channel: "latest" | "nightly"): string { +function channelCookie(channel: "latest" | "nightly" | "canary"): string { return [ `${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`, "Path=/", @@ -32,6 +33,16 @@ export const config: VercelConfig = { installCommand: "npm install -g vite-plus && vp install --ignore-scripts --filter '@t3tools/scripts...' --filter '@t3tools/web...'", routes: [ + { + src: "/__t3code/channel", + has: [matchers.query("channel", "canary")], + transforms: CLEAN_CHANNEL_QUERY_TRANSFORMS, + headers: { + Location: "/", + "Set-Cookie": channelCookie("canary"), + }, + status: 302, + }, { src: "/__t3code/channel", has: [matchers.query("channel", "nightly")], @@ -51,6 +62,11 @@ export const config: VercelConfig = { }, status: 302, }, + { + src: "/(.*)", + has: [matchers.host(ROUTER_HOST), matchers.cookie(HOSTED_WEB_CHANNEL_COOKIE, "canary")], + dest: `${CANARY_ORIGIN}/$1`, + }, { src: "/(.*)", has: [matchers.host(ROUTER_HOST), matchers.cookie(HOSTED_WEB_CHANNEL_COOKIE, "nightly")], diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index 4447c3493..4cbc5cd61 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -1,8 +1,4 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import { hasDeployChanges, @@ -179,25 +175,6 @@ describe("serializeRelayClientTracingEnvironment", () => { }); }); -describe("release workflow tracing config propagation", () => { - it.effect("uses an artifact instead of a masked cross-job token output", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workflowPath = yield* path.fromFileUrl( - new URL("../../../.github/workflows/release.yml", import.meta.url), - ); - const workflow = yield* fileSystem.readFileString(workflowPath); - - expect(workflow).not.toContain("client_tracing_token:"); - expect(workflow).not.toContain("needs.relay_public_config.outputs.client_tracing_token"); - expect(workflow).toContain('--github-env-file "$RUNNER_TEMP/relay-client-tracing.env"'); - expect(workflow).toContain("name: relay-client-tracing-config"); - expect(workflow).toContain('cat "$config_path" >> "$GITHUB_ENV"'); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); - describe("publicConfigFromOutput", () => { it("reads the complete public tracing config from persisted Alchemy output", () => { expect( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 32b090f7a..9f80fdf27 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -26,6 +26,7 @@ import { BRAND_ASSET_PATHS, resolveWebAssetBrandForChannel, type WebAssetBrand, + type WebAssetChannel, } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { @@ -2486,7 +2487,7 @@ export function resolveDesktopRuntimeDependencies( } export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( - updateChannel: "latest" | "nightly", + updateChannel: WebAssetChannel, ) { const env = yield* Config.all({ updateRepository: Config.string("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option), @@ -2506,12 +2507,13 @@ export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig" provider: "github", owner, repo, - releaseType: updateChannel === "nightly" ? "prerelease" : "release", - ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), + releaseType: updateChannel === "latest" ? "release" : "prerelease", + ...(updateChannel === "latest" ? {} : { channel: updateChannel }), }; }); -export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { +export function resolveDesktopUpdateChannel(version: string): WebAssetChannel { + if (/-canary\.\d{8}\.\d+$/.test(version)) return "canary"; return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } @@ -2524,7 +2526,15 @@ export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { } export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { - if (resolveDesktopUpdateChannel(version) === "nightly") { + const channel = resolveDesktopUpdateChannel(version); + if (channel === "canary") { + return { + macIconPng: BRAND_ASSET_PATHS.developmentDesktopIconPng, + linuxIconPng: BRAND_ASSET_PATHS.developmentUniversalIconPng, + windowsIconIco: BRAND_ASSET_PATHS.developmentWindowsIconIco, + }; + } + if (channel === "nightly") { return { macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, @@ -2557,9 +2567,10 @@ export function resolvePackageManagerUserAgent(packageManager: string): string { } export function resolveDesktopProductName(version: string): string { - return resolveDesktopUpdateChannel(version) === "nightly" - ? "T3 Code (Nightly)" - : (desktopPackageJson.productName ?? "T3 Code"); + const channel = resolveDesktopUpdateChannel(version); + if (channel === "canary") return "T3 Code (Canary)"; + if (channel === "nightly") return "T3 Code (Nightly)"; + return desktopPackageJson.productName ?? "T3 Code"; } export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( @@ -2643,12 +2654,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "mac" && target === "dmg") { + const dmgChannel = updateChannel === "canary" ? "nightly" : updateChannel; buildConfig.dmg = { // Give the themed installer its own Finder volume name. Finder caches // DMG window backgrounds by volume name, so reusing a generic name can // make a newly built background look unchanged during testing. title: `${resolveDesktopProductName(version)} ${version} Installer`, - background: `dmg/dmg-background-${updateChannel}.png`, + background: `dmg/dmg-background-${dmgChannel}.png`, window: { width: 540, // Finder counts its 32px title bar in the window bounds. The themed @@ -2666,9 +2678,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "linux") { + const executableName = updateChannel === "canary" ? "t3code-canary" : "t3code"; buildConfig.linux = { target: [target], - executableName: "t3code", + executableName, icon: "icons", category: "Development", // electron-builder turns these into MimeType=x-scheme-handler/; @@ -2682,7 +2695,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], desktop: { entry: { - StartupWMClass: "t3code", + StartupWMClass: executableName, }, }, }; @@ -3555,9 +3568,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); yield* fs.copy(distDirs.desktopResources, stageResourcesDir); if (options.platform === "mac" && options.target === "dmg") { + const dmgChannel = resolveDesktopUpdateChannel(appVersion); yield* stageDesktopDmgBackground( stageResourcesDir, - resolveDesktopUpdateChannel(appVersion), + dmgChannel === "canary" ? "nightly" : dmgChannel, options.verbose, ); } diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 2dcc6ccd6..d26a5fe5e 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -33,15 +33,17 @@ export const BRAND_ASSET_PATHS = { export type WebAssetBrand = "development" | "nightly" | "production"; -export const WEB_ASSET_CHANNELS = ["latest", "nightly"] as const; +export const WEB_ASSET_CHANNELS = ["latest", "nightly", "canary"] as const; export type WebAssetChannel = (typeof WEB_ASSET_CHANNELS)[number]; export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAssetBrand { + if (channel === "canary") return "development"; return channel === "nightly" ? "nightly" : "production"; } export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand { + if (version.includes("-canary.")) return "development"; return version.includes("-nightly.") ? "nightly" : "production"; } diff --git a/scripts/render-upstream-release-notes.mjs b/scripts/render-upstream-release-notes.mjs new file mode 100755 index 000000000..0aa2b7488 --- /dev/null +++ b/scripts/render-upstream-release-notes.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; + +function argumentValue(argv, name) { + const index = argv.indexOf(name); + const value = index === -1 ? undefined : argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value.`); + } + return value; +} + +function git(args) { + return NodeChildProcess.execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +const argv = process.argv.slice(2); +const previousTag = argumentValue(argv, "--previous-tag"); +const releaseRef = argumentValue(argv, "--release-ref"); +const upstreamRef = argumentValue(argv, "--upstream-ref"); +const outputPath = argumentValue(argv, "--output"); + +const previousBase = git(["merge-base", previousTag, upstreamRef]); +const releaseBase = git(["merge-base", releaseRef, upstreamRef]); +const commits = git([ + "log", + "--first-parent", + "--format=- %s (%h)", + `${previousBase}..${releaseBase}`, +]); +const compareUrl = `https://github.com/pingdotgg/t3code/compare/${previousBase}...${releaseBase}`; + +const body = [ + "## Upstream changes", + "", + commits || "- No upstream commits since the previous release.", + "", + `Upstream base: \`${releaseBase}\``, + `Full upstream comparison: ${compareUrl}`, + "", +].join("\n"); + +NodeFS.writeFileSync(outputPath, body); diff --git a/scripts/resolve-fork-stable-release.ts b/scripts/resolve-fork-stable-release.ts new file mode 100644 index 000000000..ea9a1c698 --- /dev/null +++ b/scripts/resolve-fork-stable-release.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +export interface ForkStableReleaseMetadata { + readonly version: string; + readonly tag: string; + readonly name: string; +} + +function parseArgs(argv: ReadonlyArray): { + readonly date: string; + readonly root: string; + readonly githubOutput: boolean; + readonly versionOnly: boolean; +} { + let date: string | undefined; + let root = process.cwd(); + let githubOutput = false; + let versionOnly = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--date") { + const value = argv[index + 1]; + if (!value) { + throw new Error("--date requires a value."); + } + date = value; + index += 1; + continue; + } + if (arg === "--root") { + const value = argv[index + 1]; + if (!value) { + throw new Error("--root requires a value."); + } + root = NodePath.resolve(value); + index += 1; + continue; + } + if (arg === "--github-output") { + githubOutput = true; + continue; + } + if (arg === "--version-only") { + versionOnly = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!date || !/^\d{8}$/.test(date)) { + throw new Error("--date must use YYYYMMDD."); + } + + return { date, root, githubOutput, versionOnly }; +} + +function readGitTags(root: string): ReadonlyArray { + return NodeChildProcess.execFileSync("git", ["tag", "--list"], { + cwd: root, + encoding: "utf8", + }) + .split("\n") + .filter((tag) => tag.length > 0); +} + +export function resolveForkStableReleaseMetadata( + date: string, + tags: ReadonlyArray, +): ForkStableReleaseMetadata { + const year = Number(date.slice(0, 4)); + const month = Number(date.slice(4, 6)); + const day = Number(date.slice(6, 8)); + const daySlot = day * 100; + const tagPattern = new RegExp(`^v?${year}\\.${month}\\.([0-9]+)$`); + let maxSequence = 0; + + for (const tag of tags) { + const match = tagPattern.exec(tag); + if (!match) { + continue; + } + + const patch = Number(match[1]); + if (patch < daySlot || patch >= daySlot + 100) { + continue; + } + + maxSequence = Math.max(maxSequence, patch - daySlot + 1); + } + + const nextSequence = maxSequence + 1; + const patch = daySlot + nextSequence - 1; + const version = `${year}.${month}.${patch}`; + return { + version, + tag: `v${version}`, + name: `T3 Code v${version}`, + }; +} + +function writeOutput(metadata: ForkStableReleaseMetadata, githubOutput: boolean): void { + const entries = [ + ["version", metadata.version], + ["tag", metadata.tag], + ["name", metadata.name], + ] as const; + + if (githubOutput) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + throw new Error("GITHUB_OUTPUT is not set."); + } + NodeFS.appendFileSync(outputPath, entries.map(([key, value]) => `${key}=${value}\n`).join("")); + return; + } + + for (const [key, value] of entries) { + process.stdout.write(`${key}=${value}\n`); + } +} + +if (import.meta.main) { + const args = parseArgs(process.argv.slice(2)); + const metadata = resolveForkStableReleaseMetadata(args.date, readGitTags(args.root)); + if (args.versionOnly) { + process.stdout.write(`${metadata.version}\n`); + } else { + writeOutput(metadata, args.githubOutput); + } +} diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index 5b42f931d..907b02600 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -20,6 +20,8 @@ export interface NightlyReleaseMetadata { } const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/)); +const PrereleaseChannel = Schema.Literals(["nightly", "canary"]); +type PrereleaseChannel = typeof PrereleaseChannel.Type; const RunNumberSchema = Schema.FiniteFromString.check( Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), @@ -101,14 +103,16 @@ export const resolveNightlyReleaseMetadata = ( date: string, runNumber: number, sha: string, + channel: PrereleaseChannel = "nightly", ) => { const shortSha = sha.slice(0, 12); - const version = `${baseVersion}-nightly.${date}.${runNumber}`; + const version = `${baseVersion}-${channel}.${date}.${runNumber}`; + const label = channel === "canary" ? "Canary" : "Nightly"; return { baseVersion, version, tag: `v${version}`, - name: `T3 Code Nightly ${version} (${shortSha})`, + name: `T3 Code ${label} ${version} (${shortSha})`, shortSha, }; }; @@ -186,9 +190,13 @@ export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")( const command = Command.make( "resolve-nightly-release", { + channel: Flag.choice("channel", PrereleaseChannel.literals).pipe( + Flag.withDescription("Prerelease channel."), + Flag.withDefault("nightly"), + ), date: Flag.string("date").pipe( Flag.withSchema(DateSchema), - Flag.withDescription("Nightly build date in YYYYMMDD."), + Flag.withDescription("Prerelease build date in YYYYMMDD."), ), runNumber: Flag.string("run-number").pipe( Flag.withSchema(RunNumberSchema), @@ -207,12 +215,14 @@ const command = Command.make( Flag.optional, ), }, - ({ date, runNumber, sha, githubOutput, root }) => + ({ channel, date, runNumber, sha, githubOutput, root }) => readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( - Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), + Effect.map((baseVersion) => + resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha, channel), + ), Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)), ), -).pipe(Command.withDescription("Resolve nightly release version metadata.")); +).pipe(Command.withDescription("Resolve nightly or canary release version metadata.")); if (import.meta.main) { Command.run(command, { version: "0.0.0" }).pipe( diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 7acc6f456..48075a695 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -11,8 +11,9 @@ import * as String from "effect/String"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -const ReleaseChannel = Schema.Literals(["stable", "nightly"]); +const ReleaseChannel = Schema.Literals(["stable", "nightly", "canary"]); type ReleaseChannel = typeof ReleaseChannel.Type; +type PrereleaseChannel = Exclude; export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( "InvalidReleaseTagError", @@ -91,7 +92,7 @@ interface StableVersion { readonly prerelease: ReadonlyArray; } -interface NightlyVersion { +interface PrereleaseVersion { readonly major: number; readonly minor: number; readonly patch: number; @@ -151,10 +152,9 @@ const parseStableTag = (tag: string): StableVersion | undefined => { if (!major || !minor || !patch) return undefined; const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; - // Nightly tags also start with `v` and carry a `nightly.*` prerelease - // identifier. They must not be considered stable candidates when resolving - // the previous stable tag. - if (prereleaseIdentifiers[0] === "nightly") return undefined; + if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "canary") { + return undefined; + } return { major: Number(major), @@ -164,7 +164,7 @@ const parseStableTag = (tag: string): StableVersion | undefined => { }; }; -const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => { +const comparePrereleaseVersions = (left: PrereleaseVersion, right: PrereleaseVersion): number => { if (left.major !== right.major) return left.major - right.major; if (left.minor !== right.minor) return left.minor - right.minor; if (left.patch !== right.patch) return left.patch - right.patch; @@ -172,10 +172,14 @@ const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): nu return left.runNumber - right.runNumber; }; -const parseNightlyTag = (tag: string): NightlyVersion | undefined => { - // Accept both the current `v` format and the legacy `nightly-v` - // format so release note diffs keep working across the tag-format transition. - const match = /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); +const parsePrereleaseTag = ( + tag: string, + channel: PrereleaseChannel, +): PrereleaseVersion | undefined => { + const legacyPrefix = channel === "nightly" ? "(?:nightly-)?" : ""; + const match = new RegExp( + `^${legacyPrefix}v(\\d+)\\.(\\d+)\\.(\\d+)-${channel}\\.(\\d{8})\\.(\\d+)$`, + ).exec(tag); if (!match) return undefined; const [, major, minor, patch, date, runNumber] = match; @@ -213,18 +217,18 @@ export const resolvePreviousReleaseTag = ( return candidates[0]?.tag; } - const current = parseNightlyTag(currentTag); + const current = parsePrereleaseTag(currentTag, channel); if (!current) { return yield* new InvalidReleaseTagError({ channel, currentTag }); } const candidates = tags - .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) + .map((tag) => ({ tag, parsed: parsePrereleaseTag(tag, channel) })) .filter( - (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined, + (entry): entry is { tag: string; parsed: PrereleaseVersion } => entry.parsed !== undefined, ) - .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); + .filter((entry) => comparePrereleaseVersions(entry.parsed, current) < 0) + .toSorted((left, right) => comparePrereleaseVersions(right.parsed, left.parsed)); return candidates[0]?.tag; }); @@ -355,7 +359,7 @@ const command = Command.make( Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)), ), -).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); +).pipe(Command.withDescription("Resolve the previous release tag for one release channel.")); if (import.meta.main) { Command.run(command, { version: "0.0.0" }).pipe( diff --git a/scripts/validate-fork-history.ts b/scripts/validate-fork-history.ts new file mode 100644 index 000000000..8114ce9b7 --- /dev/null +++ b/scripts/validate-fork-history.ts @@ -0,0 +1,244 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +const NonEmptyString = Schema.String.check(Schema.isNonEmpty()); +const ForkRef = NonEmptyString; +const PackageManifest = Schema.Struct({ version: NonEmptyString }); +const PackageManifestFiles = [ + "apps/desktop/package.json", + "apps/server/package.json", + "apps/web/package.json", + "packages/contracts/package.json", +] as const; +const ReleaseStateFiles = new Set([...PackageManifestFiles, "pnpm-lock.yaml", "nix/package.nix"]); + +const gitProcessContext = { + executable: Schema.Literal("git"), + argumentCount: NonNegativeInt, + cwd: Schema.String, +}; + +export class GitHistoryProcessError extends Schema.TaggedErrorClass()( + "GitHistoryProcessError", + { + ...gitProcessContext, + operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Git history validation failed during ${this.operation}.`; + } +} + +export class GitHistoryProcessExitError extends Schema.TaggedErrorClass()( + "GitHistoryProcessExitError", + { + ...gitProcessContext, + exitCode: Schema.Number, + stdoutLength: NonNegativeInt, + stderrLength: NonNegativeInt, + }, +) { + override get message(): string { + return `Git history validation command exited with code ${this.exitCode}.`; + } +} + +export class InvalidForkHistoryError extends Schema.TaggedErrorClass()( + "InvalidForkHistoryError", + { + reason: Schema.Literals([ + "not-based-on-upstream", + "contains-merges", + "unexpected-release-files", + "invalid-release-state-subject", + "package-versions-disagree", + ]), + detail: Schema.String, + }, +) { + override get message(): string { + return `Fork history is invalid (${this.reason}): ${this.detail}`; + } +} + +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (accumulator, chunk) => accumulator + chunk, + ), + ); + +const runGit = Effect.fn("validateForkHistory.runGit")(function* (args: ReadonlyArray) { + const cwd = process.cwd(); + const context = { + executable: "git" as const, + argumentCount: args.length, + cwd, + }; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe( + Effect.mapError( + (cause) => + new GitHistoryProcessError({ + ...context, + operation: "spawn", + cause, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout).pipe( + Effect.mapError( + (cause) => + new GitHistoryProcessError({ + ...context, + operation: "read-stdout", + cause, + }), + ), + ), + collectStreamAsString(child.stderr).pipe( + Effect.mapError( + (cause) => + new GitHistoryProcessError({ + ...context, + operation: "read-stderr", + cause, + }), + ), + ), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new GitHistoryProcessError({ + ...context, + operation: "wait-for-exit", + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + return yield* new GitHistoryProcessExitError({ + ...context, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); + } + + return stdout.trim(); +}); + +const decodePackageManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageManifest)); + +export const validateForkHistory = Effect.fn("validateForkHistory")(function* (input: { + readonly ref: string; + readonly upstreamRef: string; +}) { + const head = yield* runGit(["rev-parse", input.ref]); + const upstreamBase = yield* runGit(["rev-parse", input.upstreamRef]); + const mergeBase = yield* runGit(["merge-base", head, upstreamBase]); + if (mergeBase !== upstreamBase) { + return yield* new InvalidForkHistoryError({ + reason: "not-based-on-upstream", + detail: `${mergeBase} does not equal ${upstreamBase}`, + }); + } + + const mergeCommits = yield* runGit(["rev-list", "--merges", `${upstreamBase}..${head}`]); + if (mergeCommits.length > 0) { + return yield* new InvalidForkHistoryError({ + reason: "contains-merges", + detail: mergeCommits, + }); + } + + const changedFiles = (yield* runGit(["diff-tree", "--no-commit-id", "--name-only", "-r", head])) + .split(/\r?\n/) + .filter((file) => file.length > 0); + const unexpectedFiles = changedFiles.filter((file) => !ReleaseStateFiles.has(file)); + if (unexpectedFiles.length > 0) { + return yield* new InvalidForkHistoryError({ + reason: "unexpected-release-files", + detail: unexpectedFiles.join("\n"), + }); + } + + const commitSubject = yield* runGit(["show", "-s", "--format=%s", head]); + if ( + !commitSubject.startsWith("prepare stable release ") && + !commitSubject.startsWith("chore(release):") + ) { + return yield* new InvalidForkHistoryError({ + reason: "invalid-release-state-subject", + detail: commitSubject, + }); + } + + const versions = yield* Effect.forEach( + PackageManifestFiles, + (file) => + runGit(["show", `${head}:${file}`]).pipe( + Effect.flatMap((source) => decodePackageManifest(source)), + ), + { concurrency: "unbounded" }, + ); + const versionValues = versions.map((manifest) => manifest.version); + if (new Set(versionValues).size !== 1) { + return yield* new InvalidForkHistoryError({ + reason: "package-versions-disagree", + detail: versionValues.join(", "), + }); + } + + return { head, upstreamBase, version: versionValues[0] } as const; +}); + +const command = Command.make( + "validate-fork-history", + { + ref: Flag.string("ref").pipe( + Flag.withSchema(ForkRef), + Flag.withDescription("Git ref to validate."), + Flag.withDefault("HEAD"), + ), + upstreamRef: Flag.string("upstream-ref").pipe( + Flag.withSchema(ForkRef), + Flag.withDescription("Upstream main ref used as the history base."), + Flag.withDefault("refs/remotes/upstream/main"), + ), + }, + ({ ref, upstreamRef }) => + validateForkHistory({ ref, upstreamRef }).pipe( + Effect.flatMap(({ head, upstreamBase, version }) => + Console.log(`valid history ${head} based on ${upstreamBase} (release ${version})`), + ), + ), +).pipe(Command.withDescription("Validate the linear fork release-state history.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} From f54f77e4005a0ad922aa2bc24d75399b730bf65e Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 5 Sep 2026 09:12:03 +0300 Subject: [PATCH 2/7] chore(nix): add packaging infrastructure --- flake.nix | 31 +++++++++++ nix/README.md | 51 +++++++++++++++++ nix/desktop.nix | 87 +++++++++++++++++++++++++++++ nix/headless.nix | 30 ++++++++++ nix/package.nix | 141 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 340 insertions(+) create mode 100644 flake.nix create mode 100644 nix/README.md create mode 100644 nix/desktop.nix create mode 100644 nix/headless.nix create mode 100644 nix/package.nix diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..ad478909c --- /dev/null +++ b/flake.nix @@ -0,0 +1,31 @@ +{ + description = "T3 Code"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { nixpkgs, self }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + in + { + packages.${system} = + let + runtime = pkgs.callPackage ./nix/package.nix { src = self; }; + in + rec { + t3code-runtime = runtime; + t3code-headless = pkgs.callPackage ./nix/headless.nix { inherit runtime; }; + t3code-desktop = pkgs.callPackage ./nix/desktop.nix { + inherit runtime; + src = self; + }; + + t3code = t3code-headless; + default = t3code; + }; + + formatter.${system} = pkgs.nixfmt; + }; +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 000000000..93efd75c3 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,51 @@ +# Nix + +The flake exports headless and desktop T3 Code packages for `x86_64-linux`: + +- `t3code-headless` provides the `t3` CLI and server. +- `t3code-desktop` provides the Electron desktop application. +- `t3code` and `default` remain aliases for `t3code-headless`. + +Run the desktop application directly with: + +```console +nix build github:tarik02-org/t3code#t3code-desktop +``` + +## NixOS user service + +Add T3 Code to your flake inputs: + +```nix +inputs.t3code.url = "github:tarik02-org/t3code"; +``` + +Then add the package and user service to your NixOS configuration: + +```nix +{ inputs, pkgs, ... }: + +let + t3code = inputs.t3code.packages.${pkgs.stdenv.hostPlatform.system}.t3code-headless; +in +{ + environment.systemPackages = [ t3code ]; + + systemd.user.services.t3code = { + description = "T3 Code server"; + wantedBy = [ "default.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${t3code}/bin/t3 serve --host 0.0.0.0 --port 3773"; + WorkingDirectory = "%h"; + Environment = [ "T3CODE_NO_BROWSER=1" ]; + Restart = "on-failure"; + RestartSec = "5s"; + OOMPolicy = "continue"; + }; + }; +} +``` diff --git a/nix/desktop.nix b/nix/desktop.nix new file mode 100644 index 000000000..577265ea0 --- /dev/null +++ b/nix/desktop.nix @@ -0,0 +1,87 @@ +{ + coreutils, + electron_41, + imagemagick, + lib, + runtime, + src, + stdenvNoCC, + xdg-utils, +}: + +stdenvNoCC.mkDerivation { + pname = "t3code-desktop"; + inherit (runtime) version; + dontUnpack = true; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/bin" "$out/share/applications" "$out/share/icons/hicolor/512x512/apps" + + cat > "$out/share/applications/t3code-url-handler.desktop" < "$out/bin/t3code" <<'EOF' + #!/bin/sh + applications_dir="''${XDG_DATA_HOME:-$HOME/.local/share}/applications" + handler_name=t3code-url-handler.desktop + handler_source=${placeholder "out"}/share/applications/$handler_name + handler_target="$applications_dir/$handler_name" + + if ${lib.getExe' coreutils "mkdir"} -p "$applications_dir" && + ${lib.getExe' coreutils "install"} -m 0644 "$handler_source" "$handler_target"; then + ${lib.getExe' xdg-utils "xdg-mime"} default "$handler_name" x-scheme-handler/t3code \ + >/dev/null 2>&1 || true + ${lib.getExe' xdg-utils "xdg-mime"} default "$handler_name" x-scheme-handler/t3code-dev \ + >/dev/null 2>&1 || true + fi + + profile_user="''${USER:-$(${lib.getExe' coreutils "id"} -un)}" + export PATH="$HOME/.nix-profile/bin:/etc/profiles/per-user/$profile_user/bin:$PATH" + export T3CODE_DISABLE_AUTO_UPDATE=1 + exec ${lib.getExe electron_41} \ + --ozone-platform-hint=auto \ + --enable-features=WaylandWindowDecorations \ + ${runtime}/libexec/t3code \ + "$@" + EOF + chmod 755 "$out/bin/t3code" + + ${imagemagick}/bin/magick \ + ${src}/assets/prod/black-universal-1024.png \ + -resize 512x512 \ + "$out/share/icons/hicolor/512x512/apps/t3code.png" + + cat > "$out/share/applications/t3code.desktop" < Date: Fri, 4 Sep 2026 19:41:03 +0300 Subject: [PATCH 3/7] feat(codex): restore session controls --- apps/server/src/codexModelOptions.ts | 32 +++++++++++++ .../Layers/ProviderCommandReactor.ts | 17 ++++++- .../provider/CodexDeveloperInstructions.ts | 2 +- .../src/provider/Layers/CodexAdapter.ts | 35 +++++++++++--- .../src/provider/Layers/CodexProvider.test.ts | 15 ++++++ .../src/provider/Layers/CodexProvider.ts | 48 ++++++++++++++++++- .../provider/Layers/CodexSessionRuntime.ts | 12 +++++ 7 files changed, 151 insertions(+), 10 deletions(-) diff --git a/apps/server/src/codexModelOptions.ts b/apps/server/src/codexModelOptions.ts index 43b8a3c18..8d47124d4 100644 --- a/apps/server/src/codexModelOptions.ts +++ b/apps/server/src/codexModelOptions.ts @@ -4,6 +4,38 @@ import { getModelSelectionStringOptionValue, } from "@t3tools/shared/model"; +export const CODEX_DEFAULT_MODE_REQUEST_USER_INPUT_OPTION_ID = "defaultModeRequestUserInput"; + +export function getCodexDefaultModeRequestUserInputConfigValue( + modelSelection: ModelSelection | null | undefined, +): boolean | undefined { + const value = getModelSelectionStringOptionValue( + modelSelection, + CODEX_DEFAULT_MODE_REQUEST_USER_INPUT_OPTION_ID, + ); + switch (value) { + case "allow": + return true; + case "reject": + return false; + case "unset": + default: + return undefined; + } +} + +// `model/list` has no context-limit field; replace this allowlist when Codex exposes one. +const LONG_CONTEXT_CODEX_MODELS = new Set([ + "gpt-5.4", + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", +]); + +export function supportsCodexLongContext(model: string): boolean { + return LONG_CONTEXT_CODEX_MODELS.has(model); +} + export function getCodexServiceTierOptionValue( modelSelection: ModelSelection | null | undefined, ): string | undefined { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 5c1086b9e..9f3378170 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -14,6 +14,7 @@ import { } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -55,6 +56,7 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { getCodexDefaultModeRequestUserInputConfigValue } from "../../codexModelOptions.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); @@ -108,6 +110,7 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; +const PROVIDER_INTERRUPT_TIMEOUT = Duration.seconds(10); const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; @@ -779,6 +782,16 @@ const make = Effect.gen(function* () { activeSession?.providerInstanceId !== requestedModelSelection.instanceId; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; const previousModelSelection = threadModelSelections.get(threadId); + const codexSessionConfigChanged = + preferredProvider === "codex" && + requestedModelSelection !== undefined && + (previousModelSelection === undefined || + (getModelSelectionStringOptionValue(previousModelSelection, "contextWindow") ?? + "258k") !== + (getModelSelectionStringOptionValue(requestedModelSelection, "contextWindow") ?? + "258k") || + getCodexDefaultModeRequestUserInputConfigValue(previousModelSelection) !== + getCodexDefaultModeRequestUserInputConfigValue(requestedModelSelection)); const shouldRestartForModelSelectionChange = preferredProvider === "claudeAgent" && requestedModelSelection !== undefined && @@ -789,6 +802,7 @@ const make = Effect.gen(function* () { !cwdChanged && !instanceChanged && !shouldRestartForModelChange && + !codexSessionConfigChanged && !shouldRestartForModelSelectionChange ) { yield* refreshWorkspaceSnapshot; @@ -814,6 +828,7 @@ const make = Effect.gen(function* () { modelChanged, instanceChanged, shouldRestartForModelChange, + codexSessionConfigChanged, shouldRestartForModelSelectionChange, hasResumeCursor: resumeCursor !== undefined, }); @@ -1537,7 +1552,7 @@ const make = Effect.gen(function* () { // Orchestration turn ids are not provider turn ids, so interrupt by session. yield* providerService .interruptTurn({ threadId: event.payload.threadId }) - .pipe(Effect.catchCause(recoverInterruptFailure)); + .pipe(Effect.timeout(PROVIDER_INTERRUPT_TIMEOUT), Effect.catchCause(recoverInterruptFailure)); }); const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 1c2439a9a..9787bf1e9 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -167,7 +167,7 @@ Your active mode changes only when new developer instructions with a different \ Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. -In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. +In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, use the \`request_user_input\` tool when it is available. Otherwise, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. ${browserToolInstructions(browserToolsAvailable)} `; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5e2244336..c440d26a9 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -46,7 +46,11 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import { + getCodexDefaultModeRequestUserInputConfigValue, + getCodexServiceTierOptionValue, + supportsCodexLongContext, +} from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { @@ -2249,7 +2253,28 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const defaultModeRequestUserInput = + input.modelSelection?.instanceId === boundInstanceId + ? getCodexDefaultModeRequestUserInputConfigValue(input.modelSelection) + : undefined; + const useLongContext = + input.modelSelection?.instanceId === boundInstanceId && + getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") === "1m" && + supportsCodexLongContext(input.modelSelection.model); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const appServerArgs = [ + ...(useLongContext + ? ["-c", "model_context_window=1000000", "-c", "model_auto_compact_token_limit=900000"] + : []), + ...(mcpSession + ? [ + "-c", + `mcp_servers.t3-code.url=${mcpSession.endpoint}`, + "-c", + 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', + ] + : []), + ]; const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, @@ -2266,20 +2291,16 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { model: input.modelSelection.model } : {}), ...(serviceTier ? { serviceTier } : {}), + ...(defaultModeRequestUserInput !== undefined ? { defaultModeRequestUserInput } : {}), ...(mcpSession ? { environment: { ...(options?.environment ?? process.env), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, - appServerArgs: [ - "-c", - `mcp_servers.t3-code.url=${mcpSession.endpoint}`, - "-c", - 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', - ], } : {}), + ...(appServerArgs.length > 0 ? { appServerArgs } : {}), }; const turnTokenUsage = makeCodexTurnTokenUsageState(); const sessionScope = yield* Scope.make("sequential"); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2c..6c41f7d32 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -2,6 +2,19 @@ import { assert, it } from "@effect/vitest"; import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +const defaultModeQuestionsDescriptor = { + id: "defaultModeRequestUserInput", + label: "Default Mode Questions", + description: "Control whether Codex can ask questions while working in Default mode.", + type: "select" as const, + options: [ + { id: "unset", label: "Unset", isDefault: true }, + { id: "allow", label: "Allow" }, + { id: "reject", label: "Reject" }, + ], + currentValue: "unset", +}; + it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], @@ -61,6 +74,7 @@ it("maps current Codex model capability fields", () => { ], currentValue: "flex", }, + defaultModeQuestionsDescriptor, ]); }); @@ -100,6 +114,7 @@ it("uses standard routing when the catalog has no default service tier", () => { ], currentValue: "default", }, + defaultModeQuestionsDescriptor, ]); }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 48f67c993..b8fcbf37c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -28,6 +28,10 @@ import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/co import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { + CODEX_DEFAULT_MODE_REQUEST_USER_INPUT_OPTION_ID, + supportsCodexLongContext, +} from "../../codexModelOptions.ts"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -199,6 +203,40 @@ export function mapCodexModelCapabilities( currentValue: defaultServiceTier, }); } + if (supportsCodexLongContext(model.model)) { + optionDescriptors.push({ + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "258k", label: "258k", isDefault: true }, + { id: "1m", label: "1M" }, + ], + currentValue: "258k", + }); + } + optionDescriptors.push({ + id: CODEX_DEFAULT_MODE_REQUEST_USER_INPUT_OPTION_ID, + label: "Default Mode Questions", + description: "Control whether Codex can ask questions while working in Default mode.", + type: "select", + options: [ + { + id: "unset", + label: "Unset", + isDefault: true, + }, + { + id: "allow", + label: "Allow", + }, + { + id: "reject", + label: "Reject", + }, + ], + currentValue: "unset", + }); return createModelCapabilities({ optionDescriptors, @@ -264,6 +302,14 @@ function appendCustomCodexModels( const seen = new Set(models.map((model) => model.slug)); const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; + const customCapabilities = fallbackCapabilities + ? { + ...fallbackCapabilities, + optionDescriptors: fallbackCapabilities.optionDescriptors?.filter( + (descriptor) => descriptor.id !== "contextWindow", + ), + } + : null; const customEntries: ServerProviderModel[] = []; for (const entry of readCustomModelEntries(customModels)) { if (seen.has(entry.slug)) { @@ -274,7 +320,7 @@ function appendCustomCodexModels( slug: entry.slug, name: entry.name, isCustom: true, - capabilities: entry.capabilities ?? fallbackCapabilities, + capabilities: entry.capabilities ?? customCapabilities, }); } return customEntries.length === 0 ? models : [...models, ...customEntries]; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 4ee5845e3..5e6bfe8d4 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -164,6 +164,7 @@ export interface CodexSessionRuntimeOptions { readonly runtimeMode: RuntimeMode; readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; + readonly defaultModeRequestUserInput?: boolean | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; } @@ -532,6 +533,7 @@ function buildThreadStartParams(input: { readonly runtimeMode: RuntimeMode; readonly model: string | undefined; readonly serviceTier: CodexServiceTier | undefined; + readonly defaultModeRequestUserInput?: boolean | undefined; }): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); return { @@ -541,6 +543,13 @@ function buildThreadStartParams(input: { approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.defaultModeRequestUserInput !== undefined + ? { + config: { + "features.default_mode_request_user_input": input.defaultModeRequestUserInput, + }, + } + : {}), }; } @@ -708,6 +717,7 @@ export const openCodexThread = (input: { readonly cwd: string; readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; + readonly defaultModeRequestUserInput?: boolean | undefined; readonly resumeThreadId: string | undefined; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; @@ -716,6 +726,7 @@ export const openCodexThread = (input: { runtimeMode: input.runtimeMode, model: input.requestedModel, serviceTier: input.serviceTier, + defaultModeRequestUserInput: input.defaultModeRequestUserInput, }); if (resumeThreadId === undefined) { @@ -2270,6 +2281,7 @@ export const makeCodexSessionRuntime = ( cwd: options.cwd, requestedModel, serviceTier: options.serviceTier, + defaultModeRequestUserInput: options.defaultModeRequestUserInput, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), }); From f4dbe9dc3c2c773470caf72b677e289469debd19 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 4 Sep 2026 19:41:10 +0300 Subject: [PATCH 4/7] perf(server): bound snapshots and render frontmatter --- .../features/files/FileMarkdownPreview.tsx | 10 +- .../files/MarkdownFrontmatterTable.tsx | 107 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 98 ++++++++++++---- .../Layers/ProjectionSnapshotQuery.ts | 6 +- .../components/files/FileMarkdownPreview.tsx | 3 +- .../src/components/files/FilePreviewPanel.tsx | 51 +++++---- .../files/MarkdownFrontmatterTable.tsx | 64 +++++++++++ apps/web/src/index.css | 27 ++++- packages/client-runtime/package.json | 4 + .../client-runtime/src/markdownFrontmatter.ts | 99 ++++++++++++++++ 10 files changed, 416 insertions(+), 53 deletions(-) create mode 100644 apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx create mode 100644 apps/web/src/components/files/MarkdownFrontmatterTable.tsx create mode 100644 packages/client-runtime/src/markdownFrontmatter.ts diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index c3118c1df..a19286891 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { parseMarkdownFrontmatter } from "@t3tools/client-runtime/markdown-frontmatter"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; import { useCallback, useMemo, useState } from "react"; @@ -29,6 +30,7 @@ import { type NativeMarkdownTextStyle, } from "../../native/SelectableMarkdownText"; import { resolveWorkspaceFilePath } from "./filePath"; +import { MarkdownFrontmatterTable } from "./MarkdownFrontmatterTable"; interface MarkdownPreviewStyles { readonly theme: PartialMarkdownTheme; @@ -239,6 +241,7 @@ export function FileMarkdownPreview(props: { [markdownDirectory, props.environmentId, props.threadId], ); const styles = useMarkdownPreviewStyles(renderImage); + const frontmatter = useMemo(() => parseMarkdownFrontmatter(props.markdown), [props.markdown]); const onLinkPress = useCallback((href: string) => { void tryOpenExternalUrl(href, "markdown-link"); }, []); @@ -257,9 +260,12 @@ export function FileMarkdownPreview(props: { } > + {frontmatter.entries.length > 0 ? ( + + ) : null} {hasNativeSelectableMarkdownText() ? ( - {props.markdown} + {frontmatter.body} )} diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx new file mode 100644 index 000000000..0035dcc12 --- /dev/null +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -0,0 +1,107 @@ +import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter"; +import { useState } from "react"; +import { ScrollView, Text as NativeText, View } from "react-native"; + +const MIN_KEY_COLUMN_WIDTH = 160; +const MIN_VALUE_COLUMN_WIDTH = 400; + +function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray }) { + const occurrences = new Map(); + + return ( + + {items.map((item) => { + const occurrence = occurrences.get(item) ?? 0; + occurrences.set(item, occurrence + 1); + + return ( + + {item} + + ); + })} + + ); +} + +export function MarkdownFrontmatterTable({ + entries, +}: { + readonly entries: ReadonlyArray; +}) { + const [keyWidths, setKeyWidths] = useState>(() => new Map()); + let measuredKeyColumnWidth = MIN_KEY_COLUMN_WIDTH; + let hasEveryKeyWidth = true; + for (const entry of entries) { + const keyWidth = keyWidths.get(entry.key); + if (keyWidth === undefined) { + hasEveryKeyWidth = false; + break; + } + measuredKeyColumnWidth = Math.max(measuredKeyColumnWidth, keyWidth); + } + const keyColumnWidth = hasEveryKeyWidth ? measuredKeyColumnWidth : null; + + return ( + + + {entries.map((entry, index) => ( + + { + const measuredWidth = Math.ceil(event.nativeEvent.layout.width); + setKeyWidths((current) => { + if (current.get(entry.key) === measuredWidth) { + return current; + } + const next = new Map(current); + next.set(entry.key, measuredWidth); + return next; + }); + } + : undefined + } + > + + {entry.key} + + + + {entry.value.kind === "text" ? ( + + {entry.value.text} + + ) : entry.value.kind === "list" ? ( + + ) : ( + + {entry.value.source} + + )} + + + ))} + + + ); +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 050ad1a90..78a6442a8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -132,23 +132,16 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } -// A refresh reads each persisted summary source, so skip activities that cannot change the result. -function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { - if (event.type !== "thread.activity-appended") { - return true; - } - - switch (event.payload.activity.kind) { - case "approval.requested": - case "approval.resolved": - case "provider.approval.respond.failed": - case "user-input.requested": - case "user-input.resolved": - case "provider.user-input.respond.failed": - return true; - default: - return false; +function isStalePendingUserInputFailureDetail(detail: string | null): boolean { + if (detail === null) { + return false; } + return ( + detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request") || + detail.includes("unknown pending user input request") || + detail.includes("unknown pending codex user input request") + ); } function derivePendingUserInputCountFromActivities( @@ -184,11 +177,7 @@ function derivePendingUserInputCountFromActivities( if ( activity.kind === "provider.user-input.respond.failed" && - detail !== null && - (detail.includes("stale pending user-input request") || - detail.includes("unknown pending user-input request") || - detail.includes("unknown pending user input request") || - detail.includes("unknown pending codex user input request")) + isStalePendingUserInputFailureDetail(detail) ) { openRequestIds.delete(requestId); } @@ -910,13 +899,74 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } + + let latestUserMessageAt = existingRow.value.latestUserMessageAt; + let pendingApprovalCount = existingRow.value.pendingApprovalCount; + let pendingUserInputCount = existingRow.value.pendingUserInputCount; + let hasActionableProposedPlan = existingRow.value.hasActionableProposedPlan; + + switch (event.type) { + case "thread.proposed-plan-upserted": { + hasActionableProposedPlan = + (yield* projectionThreadProposedPlanRepository.hasActionableByThreadId({ + threadId: event.payload.threadId, + latestTurnId: existingRow.value.latestTurnId, + })) + ? 1 + : 0; + break; + } + + case "thread.activity-appended": { + if ( + event.payload.activity.kind === "approval.requested" || + event.payload.activity.kind === "approval.resolved" || + event.payload.activity.kind === "provider.approval.respond.failed" + ) { + const pendingApprovals = yield* projectionPendingApprovalRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + pendingApprovalCount = pendingApprovals.filter( + (approval) => approval.status === "pending", + ).length; + } + + if ( + event.payload.activity.kind === "user-input.requested" || + event.payload.activity.kind === "user-input.resolved" || + event.payload.activity.kind === "provider.user-input.respond.failed" + ) { + const activities = + yield* projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ + threadId: event.payload.threadId, + }); + pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); + } + break; + } + + case "thread.approval-response-requested": { + const pendingApprovals = yield* projectionPendingApprovalRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + pendingApprovalCount = pendingApprovals.filter( + (approval) => approval.status === "pending", + ).length; + break; + } + + case "thread.user-input-response-requested": + break; + } + yield* projectionThreadRepository.upsert({ ...existingRow.value, updatedAt: event.occurredAt, + latestUserMessageAt, + pendingApprovalCount, + pendingUserInputCount, + hasActionableProposedPlan, }); - if (shouldRefreshThreadShellSummary(event)) { - yield* refreshThreadShellSummary(event.payload.threadId); - } return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5f82a26e2..fa7740b5c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -661,7 +661,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { tone, kind, summary, - payload_json AS "payload", + CASE + WHEN json_extract(payload_json, '$.itemType') = 'command_execution' + THEN json_remove(payload_json, '$.data.item.aggregatedOutput') + ELSE payload_json + END AS "payload", sequence, created_at AS "createdAt" FROM projection_thread_activities diff --git a/apps/web/src/components/files/FileMarkdownPreview.tsx b/apps/web/src/components/files/FileMarkdownPreview.tsx index e36ada48a..7729b97c1 100644 --- a/apps/web/src/components/files/FileMarkdownPreview.tsx +++ b/apps/web/src/components/files/FileMarkdownPreview.tsx @@ -8,6 +8,7 @@ export function FileMarkdownPreview(props: { readonly relativePath: string; readonly text: string; readonly threadRef: ScopedThreadRef; + readonly className?: string | undefined; readonly onTaskListChange?: | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void) | undefined; @@ -27,7 +28,7 @@ export function FileMarkdownPreview(props: { cwd={props.cwd} imageBaseDir={imageBaseDir} threadRef={props.threadRef} - className="mx-auto max-w-4xl px-6 py-5" + className={props.className ?? "mx-auto max-w-4xl px-6 py-5"} onTaskListChange={props.onTaskListChange} /> ); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index b739d120d..8b191a772 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -18,6 +18,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { parseMarkdownFrontmatter } from "@t3tools/client-runtime/markdown-frontmatter"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { Code2, Eye, FolderTree, Globe2 } from "lucide-react"; import * as Schema from "effect/Schema"; @@ -66,6 +67,7 @@ import { import { installFileEditorDismissal } from "./fileEditorDismissal"; import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; +import { MarkdownFrontmatterTable } from "./MarkdownFrontmatterTable"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { isMarkdownPreviewFile, @@ -912,28 +914,39 @@ function RenderedMarkdownSurface({ relativePath, onPendingChange, }); + const frontmatter = useMemo(() => parseMarkdownFrontmatter(contents), [contents]); return ( - { - const currentContents = - getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? - contents; - const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); - if (nextContents === currentContents) return; - setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); - saveCoordinator.change(nextContents); - } - } - /> +
+ {frontmatter.entries.length > 0 ? ( + + ) : null} + 0 ? "mt-8" : ""} + onTaskListChange={ + readOnly + ? undefined + : ({ markerOffset, checked }) => { + const currentContents = + getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? + contents; + const nextContents = setMarkdownTaskChecked( + currentContents, + frontmatter.bodyOffset + markerOffset, + checked, + ); + if (nextContents === currentContents) return; + setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); + saveCoordinator.change(nextContents); + } + } + /> +
); } diff --git a/apps/web/src/components/files/MarkdownFrontmatterTable.tsx b/apps/web/src/components/files/MarkdownFrontmatterTable.tsx new file mode 100644 index 000000000..f85555e47 --- /dev/null +++ b/apps/web/src/components/files/MarkdownFrontmatterTable.tsx @@ -0,0 +1,64 @@ +import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-frontmatter"; + +import { Badge } from "~/components/ui/badge"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray }) { + const occurrences = new Map(); + + return ( + + {items.map((item) => { + const occurrence = occurrences.get(item) ?? 0; + occurrences.set(item, occurrence + 1); + + return ( + + {item} + + ); + })} + + ); +} + +export function MarkdownFrontmatterTable({ + entries, +}: { + readonly entries: ReadonlyArray; +}) { + return ( + + + + {entries.map((entry) => ( + + + + + ))} + +
+ {entry.key} + + {entry.value.kind === "text" ? ( + {entry.value.text} + ) : entry.value.kind === "list" ? ( + + ) : ( +
+                    {entry.value.source}
+                  
+ )} +
+
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a0d7b2c16..3bd895d50 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1870,29 +1870,44 @@ code { wrapping rules (overflow-wrap: anywhere) would let columns shrink to single characters and defeat the overflow — restore word-boundary wrapping so the min column width is the longest word. */ -.chat-markdown table { +.chat-markdown table, +.markdown-table { width: 100%; - min-width: max-content; border-collapse: collapse; + font-size: 0.75rem; +} + +.chat-markdown table { + min-width: max-content; overflow-wrap: normal; word-break: normal; - font-size: 0.75rem; } .chat-markdown th, -.chat-markdown td { +.chat-markdown td, +.markdown-table th, +.markdown-table td { padding: 0.45rem 0.75rem; +} + +.chat-markdown th, +.chat-markdown td, +.markdown-table td { text-align: left; } -.chat-markdown thead th { +.chat-markdown thead th, +.markdown-table thead th { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); padding-block: 0.55rem; font-weight: 600; white-space: nowrap; } -.chat-markdown tbody td { +.chat-markdown tbody th, +.chat-markdown tbody td, +.markdown-table tbody th, +.markdown-table tbody td { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); } diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index fdab38c9c..e73d69187 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -31,6 +31,10 @@ "types": "./src/markdownImages.ts", "default": "./src/markdownImages.ts" }, + "./markdown-frontmatter": { + "types": "./src/markdownFrontmatter.ts", + "default": "./src/markdownFrontmatter.ts" + }, "./markdown-links": { "types": "./src/markdownLinks.ts", "default": "./src/markdownLinks.ts" diff --git a/packages/client-runtime/src/markdownFrontmatter.ts b/packages/client-runtime/src/markdownFrontmatter.ts new file mode 100644 index 000000000..d04b53c16 --- /dev/null +++ b/packages/client-runtime/src/markdownFrontmatter.ts @@ -0,0 +1,99 @@ +import { fromYaml } from "@t3tools/shared/schemaYaml"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const FrontmatterDocument = fromYaml(Schema.Record(Schema.String, Schema.Json)); +const decodeFrontmatterDocument = Schema.decodeUnknownOption(FrontmatterDocument); +const encodeYamlValue = Schema.encodeSync(fromYaml(Schema.Json)); + +export type MarkdownFrontmatterValue = + | { readonly kind: "text"; readonly text: string } + | { + readonly kind: "list"; + readonly items: ReadonlyArray; + } + | { readonly kind: "yaml"; readonly source: string }; + +export interface MarkdownFrontmatterEntry { + readonly key: string; + readonly value: MarkdownFrontmatterValue; +} + +export interface MarkdownFrontmatter { + readonly body: string; + readonly bodyOffset: number; + readonly entries: ReadonlyArray; +} + +function displayFrontmatterValue(value: Schema.Json): MarkdownFrontmatterValue { + if (value === null) { + return { kind: "text", text: "null" }; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return { kind: "text", text: String(value) }; + } + if (Array.isArray(value) && value.length > 0) { + const items: Array = []; + for (const item of value) { + if (item === null) { + items.push("null"); + } else if ( + typeof item === "string" || + typeof item === "number" || + typeof item === "boolean" + ) { + items.push(String(item)); + } else { + return { kind: "yaml", source: encodeYamlValue(value).trimEnd() }; + } + } + return { kind: "list", items }; + } + return { kind: "yaml", source: encodeYamlValue(value).trimEnd() }; +} + +export function parseMarkdownFrontmatter(markdown: string): MarkdownFrontmatter { + const unparsed: MarkdownFrontmatter = { body: markdown, bodyOffset: 0, entries: [] }; + const openingLineEnd = markdown.indexOf("\n"); + if (openingLineEnd === -1) { + return unparsed; + } + + const openingLine = markdown.slice(0, openingLineEnd).replace(/\r$/, ""); + if (openingLine !== "---") { + return unparsed; + } + + const yamlStart = openingLineEnd + 1; + let lineStart = yamlStart; + while (lineStart <= markdown.length) { + const nextLineEnd = markdown.indexOf("\n", lineStart); + const lineEnd = nextLineEnd === -1 ? markdown.length : nextLineEnd; + const line = markdown.slice(lineStart, lineEnd).replace(/\r$/, ""); + + if (line === "---") { + const decoded = decodeFrontmatterDocument(markdown.slice(yamlStart, lineStart)); + if (Option.isNone(decoded)) { + return unparsed; + } + + const entries = Object.entries(decoded.value).map(([key, value]) => ({ + key, + value: displayFrontmatterValue(value), + })); + const bodyOffset = nextLineEnd === -1 ? lineEnd : nextLineEnd + 1; + return { + body: markdown.slice(bodyOffset), + bodyOffset, + entries, + }; + } + + if (nextLineEnd === -1) { + break; + } + lineStart = nextLineEnd + 1; + } + + return unparsed; +} From 727d1426f6ad52fbddaf8e6bbd694cb0424d7db2 Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 4 Sep 2026 19:41:18 +0300 Subject: [PATCH 5/7] feat(goals): add sidecar-backed thread goals --- .../features/threads/ThreadDetailScreen.tsx | 89 +++++- .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/state/use-thread-composer-state.ts | 62 ++++ apps/server/src/config.ts | 3 + .../Layers/ProjectionPipeline.ts | 30 ++ .../Layers/ProjectionSnapshotQuery.ts | 51 ++- .../Layers/ProviderCommandReactor.ts | 55 ++++ .../Layers/ProviderRuntimeIngestion.ts | 143 ++++++++- apps/server/src/orchestration/decider.ts | 63 ++++ apps/server/src/orchestration/projector.ts | 24 ++ apps/server/src/persistence/ForkMigrations.ts | 31 ++ .../001_ProjectionThreadGoals.ts | 3 + apps/server/src/persistence/GoalTable.ts | 18 ++ .../persistence/Layers/ProjectionThreads.ts | 54 +++- apps/server/src/persistence/Layers/Sqlite.ts | 48 +-- .../src/persistence/RuntimeSqliteLayer.ts | 27 ++ .../Services/ProjectionThreadGoals.ts | 221 +++++++++++++ .../persistence/Services/ProjectionThreads.ts | 2 + .../src/provider/Layers/CodexAdapter.ts | 29 +- .../provider/Layers/CodexSessionRuntime.ts | 87 ++++++ .../src/provider/Layers/ProviderService.ts | 46 +++ .../src/provider/Services/ProviderAdapter.ts | 9 + .../src/provider/Services/ProviderService.ts | 8 + apps/server/src/ws.ts | 8 +- apps/web/src/components/ChatView.tsx | 291 ++++++++++++++++++ apps/web/src/goalPresentation.ts | 40 +++ .../client-runtime/src/operations/commands.ts | 13 + .../src/state/threadCommands.ts | 70 ++++- .../client-runtime/src/state/threadReducer.ts | 20 ++ packages/contracts/src/orchestration.ts | 98 ++++++ packages/contracts/src/provider.ts | 7 + packages/contracts/src/providerRuntime.ts | 48 +++ 32 files changed, 1645 insertions(+), 54 deletions(-) create mode 100644 apps/server/src/persistence/ForkMigrations.ts create mode 100644 apps/server/src/persistence/ForkMigrations/001_ProjectionThreadGoals.ts create mode 100644 apps/server/src/persistence/GoalTable.ts create mode 100644 apps/server/src/persistence/RuntimeSqliteLayer.ts create mode 100644 apps/server/src/persistence/Services/ProjectionThreadGoals.ts create mode 100644 apps/web/src/goalPresentation.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 57a93e0cf..f4ec9135f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -16,11 +16,13 @@ import type { EnvironmentId, MessageId, ModelSelection, + OrchestrationThreadGoal, OrchestrationThreadShell, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + ThreadGoalRequest, ThreadId, UsageLimitsReport, UserInputQuestion, @@ -41,6 +43,8 @@ import { AppState, Keyboard, Platform, + Pressable, + Text, useWindowDimensions, View, type GestureResponderEvent, @@ -144,6 +148,7 @@ export interface ThreadDetailScreenProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + readonly onRequestGoal: (request: ThreadGoalRequest) => Promise; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -242,6 +247,23 @@ const USER_INPUT_TOGGLE_TIMING = { easing: Easing.out(Easing.cubic), }; +function goalStatusLabel(status: OrchestrationThreadGoal["status"]): string { + switch (status) { + case "active": + return "Active"; + case "paused": + return "Paused"; + case "blocked": + return "Blocked"; + case "usageLimited": + return "Usage limited"; + case "budgetLimited": + return "Budget limited"; + case "complete": + return "Complete"; + } +} + export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const insets = useSafeAreaInsets(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); @@ -291,6 +313,23 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [composerFocused, setComposerFocused] = useState(false); + const [pendingGoalAction, setPendingGoalAction] = useState<"pause" | "resume" | "clear" | null>( + null, + ); + const handleGoalControl = useCallback( + async (action: "pause" | "resume" | "clear") => { + if (props.selectedThread.goal === undefined || props.selectedThread.goal === null) { + return; + } + setPendingGoalAction(action); + try { + await props.onRequestGoal({ kind: "control", action }); + } finally { + setPendingGoalAction(null); + } + }, + [props.onRequestGoal, props.selectedThread.goal], + ); const handleComposerFocusChange = useCallback( (focused: boolean) => { setComposerFocused(focused); @@ -915,7 +954,55 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread environmentId={props.environmentId} onClose={dismissUsageLimits} /> - + + ) : null} + {props.selectedThread.goal ? ( + + + Goal {goalStatusLabel(props.selectedThread.goal.status)} + + + {props.selectedThread.goal.objective} + + + + void handleGoalControl( + props.selectedThread.goal?.status === "active" ? "pause" : "resume", + ) + } + className="rounded-md bg-secondary px-3 py-1.5" + > + + {pendingGoalAction === "pause" + ? "Pausing..." + : pendingGoalAction === "resume" + ? "Resuming..." + : props.selectedThread.goal.status === "active" + ? "Pause" + : "Resume"} + + + void handleGoalControl("clear")} + className="rounded-md px-3 py-1.5" + > + + {pendingGoalAction === "clear" ? "Clearing..." : "Clear"} + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( { ensureComposerDraftsLoaded(); @@ -353,6 +359,44 @@ export function useThreadComposerState() { return null; } + const goalCommand = + attachments.length === 0 && + (provider?.driver === "codex" || thread.session?.providerName === "codex") + ? parseCodexGoalCommand(text) + : null; + if (goalCommand) { + if (goalCommand.kind === "invalid") { + Alert.alert("Invalid Goal command", goalCommand.message); + return null; + } + if (thread.session === null && goalCommand.kind !== "set") { + Alert.alert( + "Start a Codex thread first", + "Set a goal objective before checking its status.", + ); + return null; + } + clearComposerDraftContent(threadKey); + const result = await requestThreadGoal({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + request: goalCommand, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = Cause.squash(result.cause); + Alert.alert( + "Goal command failed", + error instanceof Error ? error.message : "Failed to send Goal command.", + ); + } + return null; + } + return null; + } + const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue publishes the queued atom synchronously (the durable write @@ -402,8 +446,25 @@ export function useThreadComposerState() { selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, + requestThreadGoal, ]); + const onRequestGoal = useCallback( + async (request: ThreadGoalRequest) => { + if (!selectedThreadShell) { + return null; + } + return await requestThreadGoal({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + request, + }, + }); + }, + [requestThreadGoal, selectedThreadShell], + ); + const onChangeDraftMessage = useCallback( (value: string) => { if (!selectedThreadShell) { @@ -602,6 +663,7 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onRequestGoal, onUpdateModelSelection, onUpdateRuntimeMode, onUpdateInteractionMode, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 42df3814b..88a0f1396 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -31,6 +31,7 @@ export type StartupPresentation = typeof StartupPresentation.Type; export interface ServerDerivedPaths { readonly stateDir: string; readonly dbPath: string; + readonly forkDbPath: string; readonly keybindingsConfigPath: string; readonly settingsPath: string; /** Palettes this machine publishes for clients to follow, one file per theme. */ @@ -112,6 +113,7 @@ export const deriveServerPaths = Effect.fn(function* ( devUrl !== undefined && !options.baseDirIsExplicit ? "dev" : "userdata", ); const dbPath = join(stateDir, "state.sqlite"); + const forkDbPath = join(stateDir, "state-tarik02.sqlite"); const attachmentsDir = join(stateDir, "attachments"); const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); @@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* ( return { stateDir, dbPath, + forkDbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), environmentThemesDir: join(stateDir, "themes"), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 78a6442a8..138abb4a3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -987,6 +987,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.goal-updated": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + goal: event.payload.goal, + updatedAt: event.occurredAt, + }); + return; + } + + case "thread.goal-cleared": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + goal: null, + updatedAt: event.occurredAt, + }); + return; + } + case "thread.turn-diff-completed": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index fa7740b5c..a1397338b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -55,6 +55,7 @@ import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionT import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { ProjectionThreadGoalRepository } from "../../persistence/Services/ProjectionThreadGoals.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { decodeThreadDetailPageCursor, @@ -112,13 +113,17 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema Struct.assign({ hasOtherUserMessages: Schema.Number }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( +const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(Struct.omit(["goal"])).mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); +const ProjectionThreadHydratedRowSchema = ProjectionThreadDbRowSchema.mapFields( + Struct.assign({ goal: ProjectionThread.fields.goal }), +); +type ProjectionThreadHydratedRow = typeof ProjectionThreadHydratedRowSchema.Type; const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -429,6 +434,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const threadPlanProgress = yield* ThreadPlanProgressService; const sql = yield* SqlClient.SqlClient; + const projectionThreadGoalRepository = yield* ProjectionThreadGoalRepository; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; const resolveRepositoryIdentitiesForProjects = Effect.fn( @@ -463,6 +469,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const withThreadGoals = ( + rows: ReadonlyArray>, + ): Effect.Effect, ProjectionRepositoryError> => + rows.length === 0 + ? Effect.succeed([]) + : projectionThreadGoalRepository + .getByThreadIds({ + threadIds: rows.map((row) => row.threadId), + }) + .pipe( + Effect.map( + (goals) => + rows.map((row) => ({ + ...row, + goal: goals.get(row.threadId) ?? null, + })) satisfies ReadonlyArray, + ), + ); + const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionProjectDbRowSchema, @@ -1833,6 +1858,7 @@ pending_approval_requests AS ( ), ), listThreadRows(undefined).pipe( + Effect.flatMap(withThreadGoals), Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getSnapshot:listThreads:query", @@ -2093,6 +2119,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, + ...(row.goal === null || row.goal === undefined ? {} : { goal: row.goal }), messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], @@ -2135,6 +2162,7 @@ pending_approval_requests AS ( ), ), listThreadRows(undefined).pipe( + Effect.flatMap(withThreadGoals), Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getCommandReadModel:listThreads:query", @@ -2308,6 +2336,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, + ...(row.goal === null || row.goal === undefined ? {} : { goal: row.goal }), messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: [], @@ -2345,6 +2374,7 @@ pending_approval_requests AS ( ), ), listActiveThreadRows(undefined).pipe( + Effect.flatMap(withThreadGoals), Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", @@ -2450,6 +2480,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, + ...(row.goal === null || row.goal === undefined ? {} : { goal: row.goal }), latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, @@ -2494,6 +2525,7 @@ pending_approval_requests AS ( ), ), listArchivedThreadRows(undefined).pipe( + Effect.flatMap(withThreadGoals), Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreads:query", @@ -2600,6 +2632,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, + ...(row.goal === null || row.goal === undefined ? {} : { goal: row.goal }), latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, @@ -2867,6 +2900,11 @@ pending_approval_requests AS ( Effect.gen(function* () { const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : withThreadGoals([option.value]).pipe(Effect.map((rows) => Option.some(rows[0]!))), + ), Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadShellById:getThread:query", @@ -2923,6 +2961,9 @@ pending_approval_requests AS ( activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + ...(threadRow.value.goal === null || threadRow.value.goal === undefined + ? {} + : { goal: threadRow.value.goal }), latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, @@ -3122,6 +3163,11 @@ pending_approval_requests AS ( sessionRow, ] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : withThreadGoals([option.value]).pipe(Effect.map((rows) => Option.some(rows[0]!))), + ), Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", @@ -3206,6 +3252,9 @@ pending_approval_requests AS ( activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, + ...(threadRow.value.goal === null || threadRow.value.goal === undefined + ? {} + : { goal: threadRow.value.goal }), messages: messageRows.map((row) => { const message = { id: row.messageId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9f3378170..bdbc231fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -72,6 +72,7 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" + | "thread.goal-requested" | "thread.session-stop-requested" | "thread.settled"; } @@ -358,6 +359,7 @@ const make = Effect.gen(function* () { | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" | "provider.user-input.respond.failed" + | "provider.goal.request.failed" | "provider.session.stop.failed"; readonly summary: string; readonly detail: string; @@ -1555,6 +1557,55 @@ const make = Effect.gen(function* () { .pipe(Effect.timeout(PROVIDER_INTERRUPT_TIMEOUT), Effect.catchCause(recoverInterruptFailure)); }); + const processGoalRequested = Effect.fn("processGoalRequested")(function* ( + event: Extract, + ) { + const thread = yield* resolveThreadShell(event.payload.threadId); + if (!thread) { + return; + } + + const recoverGoalRequestFailure = (cause: Cause.Cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.goal.request.failed", + summary: "Provider goal request failed", + detail: formatFailureDetail(cause), + turnId: null, + createdAt: event.payload.createdAt, + }); + + const ready = yield* ensureSessionForThread( + event.payload.threadId, + event.payload.createdAt, + ).pipe( + Effect.as(true), + Effect.catchCause((cause) => recoverGoalRequestFailure(cause).pipe(Effect.as(false))), + ); + if (!ready) { + return; + } + + if (!providerService.sendGoalRequest) { + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.goal.request.failed", + summary: "Provider goal request failed", + detail: "The active provider service does not support goal requests.", + turnId: null, + createdAt: event.payload.createdAt, + }); + return; + } + + yield* providerService + .sendGoalRequest({ + threadId: event.payload.threadId, + request: event.payload.request, + }) + .pipe(Effect.catchCause(recoverGoalRequestFailure)); + }); + const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( event: Extract, ) { @@ -1741,6 +1792,9 @@ const make = Effect.gen(function* () { case "thread.turn-interrupt-requested": yield* processTurnInterruptRequested(event); return; + case "thread.goal-requested": + yield* processGoalRequested(event); + return; case "thread.approval-response-requested": yield* processApprovalResponseRequested(event); return; @@ -1804,6 +1858,7 @@ const make = Effect.gen(function* () { event.type === "thread.runtime-mode-set" || event.type === "thread.turn-start-requested" || event.type === "thread.turn-interrupt-requested" || + event.type === "thread.goal-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || event.type === "thread.session-stop-requested" || diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d34fee4f..7272d52a6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -14,6 +14,7 @@ import { TurnId, type OrchestrationCheckpointSummary, type OrchestrationThreadActivity, + type OrchestrationThreadGoal, type ProviderRuntimeEvent, RuntimeRequestId, } from "@t3tools/contracts"; @@ -97,12 +98,16 @@ interface AssistantSegmentState { activeMessageId: MessageId | null; } +type GoalActivityState = Pick; + const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); +const GOAL_ACTIVITY_STATE_BY_THREAD_CACHE_CAPACITY = 10_000; +const GOAL_ACTIVITY_STATE_BY_THREAD_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; @@ -167,6 +172,48 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } +function epochMsOrSecondsToIso(value: number, fallbackIso: string): string { + const milliseconds = Math.abs(value) < 10_000_000_000 ? value * 1_000 : value; + if (!Number.isFinite(milliseconds)) { + return fallbackIso; + } + return Option.match(DateTime.make(milliseconds), { + onNone: () => fallbackIso, + onSome: DateTime.formatIso, + }); +} + +function goalUpdatedActivitySummary( + previousGoal: GoalActivityState | null | undefined, + goal: Extract["payload"], +): string | null { + if (previousGoal?.objective === goal.objective && previousGoal.status === goal.status) { + return null; + } + if (!previousGoal || previousGoal.objective !== goal.objective) { + return "Goal set"; + } + switch (goal.status) { + case "active": + return previousGoal.status === "paused" || + previousGoal.status === "budgetLimited" || + previousGoal.status === "blocked" || + previousGoal.status === "usageLimited" + ? "Goal resumed" + : null; + case "paused": + return "Goal paused"; + case "blocked": + return "Goal blocked"; + case "usageLimited": + return "Goal usage limited"; + case "budgetLimited": + return "Goal budget limited"; + case "complete": + return "Goal complete"; + } +} + function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string | undefined { const trimmed = planMarkdown?.trim(); if (!trimmed) { @@ -351,7 +398,7 @@ function taskLinkageActivityFields(payload: Record): Record { const maybeSequence = (() => { const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; @@ -718,7 +765,7 @@ export function runtimeEventToActivities( payload: { taskId: event.payload.taskId, status: event.payload.status, - ...(taskTitle ? { title: truncateDetail(taskTitle, 120) } : {}), + ...(context?.taskTitle ? { title: truncateDetail(context.taskTitle, 120) } : {}), // summary + detail mirror task.progress: clients label the row from // summary and keep detail for the preview/expanded body. ...(event.payload.summary @@ -787,6 +834,50 @@ export function runtimeEventToActivities( ]; } + case "thread.goal.updated": { + const summary = goalUpdatedActivitySummary(context?.previousGoal, event.payload); + if (summary === null) { + return []; + } + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "goal.updated", + summary, + payload: { + status: event.payload.status, + detail: truncateDetail(event.payload.objective), + objective: event.payload.objective, + tokensUsed: event.payload.tokensUsed, + tokenBudget: event.payload.tokenBudget, + timeUsedSeconds: event.payload.timeUsedSeconds, + }, + turnId: null, + ...maybeSequence, + }, + ]; + } + + case "thread.goal.cleared": { + if (!context?.previousGoal) { + return []; + } + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "goal.cleared", + summary: "Goal cleared", + payload: {}, + turnId: null, + ...maybeSequence, + }, + ]; + } + case "item.updated": { if (!isToolLifecycleItemType(event.payload.itemType)) { return []; @@ -942,6 +1033,12 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + const goalActivityStateByThreadId = yield* Cache.make({ + capacity: GOAL_ACTIVITY_STATE_BY_THREAD_CACHE_CAPACITY, + timeToLive: GOAL_ACTIVITY_STATE_BY_THREAD_TTL, + lookup: () => Effect.die(new Error("goal activity state should be read through getOption")), + }); + // Task names arrive on task.started/task.progress but not on task.completed, // so remember them per task to title the completion activity. const taskDescriptionByTaskKey = yield* Cache.make({ @@ -2072,6 +2169,43 @@ const make = Effect.gen(function* () { } } + let previousGoalForActivity: GoalActivityState | null = null; + if (event.type === "thread.goal.updated" || event.type === "thread.goal.cleared") { + const cachedGoal = yield* Cache.getOption(goalActivityStateByThreadId, thread.id); + previousGoalForActivity = Option.isSome(cachedGoal) + ? cachedGoal.value + : ((yield* resolveThreadDetail(thread.id))?.goal ?? null); + } + if (event.type === "thread.goal.updated") { + yield* orchestrationEngine.dispatch({ + type: "thread.goal.update", + commandId: yield* providerCommandId(event, "thread-goal-update"), + threadId: thread.id, + goal: { + objective: event.payload.objective, + status: event.payload.status, + tokensUsed: event.payload.tokensUsed, + tokenBudget: event.payload.tokenBudget, + timeUsedSeconds: event.payload.timeUsedSeconds, + createdAt: epochMsOrSecondsToIso(event.payload.createdAtEpochMsOrSeconds, now), + updatedAt: epochMsOrSecondsToIso(event.payload.updatedAtEpochMsOrSeconds, now), + }, + createdAt: now, + }); + yield* Cache.set(goalActivityStateByThreadId, thread.id, { + objective: event.payload.objective, + status: event.payload.status, + }); + } else if (event.type === "thread.goal.cleared") { + yield* orchestrationEngine.dispatch({ + type: "thread.goal.clear", + commandId: yield* providerCommandId(event, "thread-goal-clear"), + threadId: thread.id, + createdAt: now, + }); + yield* Cache.invalidate(goalActivityStateByThreadId, thread.id); + } + let activityEvent = event; if ( isCompactedThreadState && @@ -2126,7 +2260,10 @@ const make = Effect.gen(function* () { } } - const activities = runtimeEventToActivities(activityEvent, taskTitle); + const activities = runtimeEventToActivities(activityEvent, { + previousGoal: previousGoalForActivity, + ...(taskTitle ? { taskTitle } : {}), + }); yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 37ab4730f..b943997b6 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1449,6 +1449,47 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return [unsettledEvent, sessionSetEvent]; } + case "thread.goal.update": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.goal-updated", + payload: { + threadId: command.threadId, + goal: command.goal, + }, + }; + } + + case "thread.goal.clear": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.goal-cleared", + payload: { + threadId: command.threadId, + }, + }; + } + case "thread.message.assistant.delta": { if (isImportedAgentSessionMessageId(command.messageId)) { return yield* new OrchestrationCommandInvariantError({ @@ -1657,6 +1698,28 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.goal.request": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.goal-requested", + payload: { + threadId: command.threadId, + request: command.request, + createdAt: command.createdAt, + }, + }; + } + case "thread.activity.append": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c048247f4..ecad37d18 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -5,6 +5,8 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + ThreadGoalClearedPayload, + ThreadGoalUpdatedPayload, } from "@t3tools/contracts"; import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; @@ -675,6 +677,28 @@ export function projectEvent( }; }); + case "thread.goal-updated": + return decodeForEvent(ThreadGoalUpdatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + goal: payload.goal, + updatedAt: event.occurredAt, + }), + })), + ); + + case "thread.goal-cleared": + return decodeForEvent(ThreadGoalClearedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + goal: null, + updatedAt: event.occurredAt, + }), + })), + ); + case "thread.proposed-plan-upserted": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/ForkMigrations.ts b/apps/server/src/persistence/ForkMigrations.ts new file mode 100644 index 000000000..c6eef2b52 --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations.ts @@ -0,0 +1,31 @@ +/** + * ForkMigrations - migration runner for fork-only SQLite state. + */ + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Migrator from "effect/unstable/sql/Migrator"; + +import Migration0001 from "./ForkMigrations/001_ProjectionThreadGoals.ts"; + +export const migrationEntries = [[1, "ProjectionThreadGoals", Migration0001]] as const; + +export const makeMigrationLoader = () => + Migrator.fromRecord( + Object.fromEntries( + migrationEntries.map(([id, name, migration]) => [`${id}_${name}`, migration]), + ), + ); + +const run = Migrator.make({}); + +export const runForkMigrations = Effect.fn("runForkMigrations")(function* () { + yield* Effect.log("Running fork migrations..."); + const executedMigrations = yield* run({ loader: makeMigrationLoader() }); + yield* Effect.log("Fork migrations ran successfully").pipe( + Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), + ); + return executedMigrations; +}); + +export const ForkMigrationsLive = Layer.effectDiscard(runForkMigrations()); diff --git a/apps/server/src/persistence/ForkMigrations/001_ProjectionThreadGoals.ts b/apps/server/src/persistence/ForkMigrations/001_ProjectionThreadGoals.ts new file mode 100644 index 000000000..f89ddb6eb --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations/001_ProjectionThreadGoals.ts @@ -0,0 +1,3 @@ +import { ensureGoalTable } from "../GoalTable.ts"; + +export default ensureGoalTable(); diff --git a/apps/server/src/persistence/GoalTable.ts b/apps/server/src/persistence/GoalTable.ts new file mode 100644 index 000000000..4cf7b0d05 --- /dev/null +++ b/apps/server/src/persistence/GoalTable.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export const ensureGoalTable = Effect.fn("ensureGoalTable")(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_goals ( + thread_id TEXT PRIMARY KEY NOT NULL, + goal_json TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_goals_status + ON projection_thread_goals(json_extract(goal_json, '$.status')) + `; +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 6406e8237..404c5fb88 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -2,10 +2,12 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { toPersistenceSqlError } from "../Errors.ts"; +import { toPersistenceSqlError, type ProjectionRepositoryError } from "../Errors.ts"; +import { ProjectionThreadGoalRepository } from "../Services/ProjectionThreadGoals.ts"; import { DeleteProjectionThreadInput, GetProjectionThreadInput, @@ -16,7 +18,7 @@ import { } from "../Services/ProjectionThreads.ts"; import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; -const ProjectionThreadDbRow = ProjectionThread.mapFields( +const ProjectionThreadDbRow = ProjectionThread.mapFields(Struct.omit(["goal"])).mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), @@ -24,9 +26,11 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; +type ProjectionThreadHydratedRow = Schema.Schema.Type; const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const projectionThreadGoalRepository = yield* ProjectionThreadGoalRepository; const upsertProjectionThreadRow = SqlSchema.void({ Request: ProjectionThread, @@ -208,6 +212,25 @@ const makeProjectionThreadRepository = Effect.gen(function* () { `, }); + const withThreadGoals = ( + rows: ReadonlyArray, + ): Effect.Effect, ProjectionRepositoryError> => + rows.length === 0 + ? Effect.succeed([]) + : projectionThreadGoalRepository + .getByThreadIds({ + threadIds: rows.map((row) => row.threadId), + }) + .pipe( + Effect.map( + (goals) => + rows.map((row) => ({ + ...row, + goal: goals.get(row.threadId) ?? null, + })) satisfies ReadonlyArray, + ), + ); + const deleteProjectionThreadRow = SqlSchema.void({ Request: DeleteProjectionThreadInput, execute: ({ threadId }) => @@ -218,24 +241,39 @@ const makeProjectionThreadRepository = Effect.gen(function* () { }); const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => - upsertProjectionThreadRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), - ); + Effect.gen(function* () { + yield* upsertProjectionThreadRow({ ...row, goal: null }); + if (row.goal === undefined || row.goal === null) { + yield* projectionThreadGoalRepository.deleteByThreadId({ threadId: row.threadId }); + } else { + yield* projectionThreadGoalRepository.upsert({ + threadId: row.threadId, + goal: row.goal, + }); + } + }).pipe(Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query"))); const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : withThreadGoals([option.value]).pipe(Effect.map((rows) => Option.some(rows[0]!))), + ), Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( + Effect.flatMap(withThreadGoals), Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => - deleteProjectionThreadRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), - ); + Effect.gen(function* () { + yield* deleteProjectionThreadRow(input); + yield* projectionThreadGoalRepository.deleteByThreadId({ threadId: input.threadId }); + }).pipe(Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query"))); return { upsert, diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 41d8f5baf..6197a3fef 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -3,32 +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 { 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); +import { ProjectionThreadGoalRepositoryLive } from "../Services/ProjectionThreadGoals.ts"; +import { makeRuntimeSqliteLayer } from "../RuntimeSqliteLayer.ts"; const setup = Layer.effectDiscard( Effect.gen(function* () { @@ -49,20 +28,23 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); return Layer.provideMerge( - setup, - makeRuntimeSqliteLayer({ - filename: dbPath, - spanAttributes: { - "db.name": path.basename(dbPath), - "service.name": "t3-server", - }, - }), + ProjectionThreadGoalRepositoryLive, + Layer.provideMerge( + setup, + makeRuntimeSqliteLayer({ + filename: dbPath, + spanAttributes: { + "db.name": path.basename(dbPath), + "service.name": "t3-server", + }, + }), + ), ); }, Layer.unwrap); export const SqlitePersistenceMemory = Layer.provideMerge( - setup, - makeRuntimeSqliteLayer({ filename: ":memory:" }), + ProjectionThreadGoalRepositoryLive, + Layer.provideMerge(setup, makeRuntimeSqliteLayer({ filename: ":memory:" })), ); export const layerConfig = Layer.unwrap( diff --git a/apps/server/src/persistence/RuntimeSqliteLayer.ts b/apps/server/src/persistence/RuntimeSqliteLayer.ts new file mode 100644 index 000000000..125427cbe --- /dev/null +++ b/apps/server/src/persistence/RuntimeSqliteLayer.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +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>; + +export 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); diff --git a/apps/server/src/persistence/Services/ProjectionThreadGoals.ts b/apps/server/src/persistence/Services/ProjectionThreadGoals.ts new file mode 100644 index 000000000..17bb2bae8 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadGoals.ts @@ -0,0 +1,221 @@ +import { OrchestrationThreadGoal, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { ServerConfig } from "../../config.ts"; +import { toPersistenceSqlError, type ProjectionRepositoryError } from "../Errors.ts"; +import { makeRuntimeSqliteLayer } from "../RuntimeSqliteLayer.ts"; +import { ensureGoalTable } from "../GoalTable.ts"; +import { runForkMigrations } from "../ForkMigrations.ts"; + +export interface ProjectionThreadGoalRepositoryShape { + readonly getByThreadId: (input: { + readonly threadId: ThreadId; + }) => Effect.Effect< + Option.Option>, + ProjectionRepositoryError + >; + readonly getByThreadIds: (input: { + readonly threadIds: ReadonlyArray; + }) => Effect.Effect< + ReadonlyMap>, + ProjectionRepositoryError + >; + readonly upsert: (input: { + readonly threadId: ThreadId; + readonly goal: Schema.Schema.Type; + }) => Effect.Effect; + readonly deleteByThreadId: (input: { + readonly threadId: ThreadId; + }) => Effect.Effect; +} + +export class ProjectionThreadGoalRepository extends Context.Service< + ProjectionThreadGoalRepository, + ProjectionThreadGoalRepositoryShape +>()("t3/persistence/Services/ProjectionThreadGoals/ProjectionThreadGoalRepository") {} + +const ProjectionThreadGoalDbRow = Schema.Struct({ + threadId: ThreadId, + goal: Schema.fromJsonString(OrchestrationThreadGoal), +}); + +const ProjectionThreadGoalJson = Schema.fromJsonString(OrchestrationThreadGoal); +const encodeProjectionThreadGoalJson = Schema.encodeUnknownEffect(ProjectionThreadGoalJson); +const decodeProjectionThreadGoalJson = Schema.decodeUnknownEffect(ProjectionThreadGoalJson); + +const LegacyGoalRow = Schema.Struct({ + threadId: ThreadId, + goalJson: Schema.String, +}); + +const ListProjectionThreadGoalsInput = Schema.Struct({ + threadIds: Schema.Array(ThreadId), +}); + +const buildForkClientLayer = (forkDbPath: string) => + makeRuntimeSqliteLayer({ + filename: forkDbPath, + spanAttributes: { + "db.name": "state-tarik02.sqlite", + "service.name": "t3-server", + }, + }); + +const upsertGoal = Effect.fn("upsertGoal")(function* (input: { + readonly threadId: ThreadId; + readonly goal: Schema.Schema.Type; +}) { + const forkSql = yield* SqlClient.SqlClient; + const goalJson = yield* encodeProjectionThreadGoalJson(input.goal); + yield* forkSql` + INSERT INTO projection_thread_goals (thread_id, goal_json) + VALUES (${input.threadId}, ${goalJson}) + ON CONFLICT(thread_id) + DO UPDATE SET goal_json = excluded.goal_json + `; +}); + +const deleteGoal = Effect.fn("deleteGoal")(function* (input: { readonly threadId: ThreadId }) { + const forkSql = yield* SqlClient.SqlClient; + yield* forkSql` + DELETE FROM projection_thread_goals + WHERE thread_id = ${input.threadId} + `; +}); + +const listGoalsByThreadIds = Effect.fn("listGoalsByThreadIds")(function* (input: { + readonly threadIds: ReadonlyArray; +}) { + const forkSql = yield* SqlClient.SqlClient; + return yield* SqlSchema.findAll({ + Request: ListProjectionThreadGoalsInput, + Result: ProjectionThreadGoalDbRow, + execute: ({ threadIds }) => + forkSql` + SELECT + thread_id AS "threadId", + goal_json AS "goal" + FROM projection_thread_goals + WHERE thread_id IN ${forkSql.in(threadIds)} + `, + })(input); +}); + +export const ProjectionThreadGoalRepositoryLive = Layer.effect( + ProjectionThreadGoalRepository, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const maybeServerConfig = yield* Effect.serviceOption(ServerConfig); + const maybeFs = yield* Effect.serviceOption(FileSystem.FileSystem); + const maybePath = yield* Effect.serviceOption(Path.Path); + const forkDbPath = Option.match(maybeServerConfig, { + onNone: () => ":memory:", + onSome: (serverConfig) => serverConfig.forkDbPath, + }); + + if (Option.isSome(maybeFs) && Option.isSome(maybePath) && forkDbPath !== ":memory:") { + yield* maybeFs.value.makeDirectory(maybePath.value.dirname(forkDbPath), { recursive: true }); + } + + const forkContext = yield* Layer.build(buildForkClientLayer(forkDbPath)); + + const legacyGoalColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const hasLegacyGoalColumn = legacyGoalColumns.some((column) => column.name === "goal_json"); + + if (hasLegacyGoalColumn) { + const legacyRows = yield* sql>` + SELECT + thread_id AS "threadId", + goal_json AS "goalJson" + FROM projection_threads + WHERE goal_json IS NOT NULL + `; + + if (legacyRows.length > 0) { + yield* ensureGoalTable().pipe(Effect.provide(forkContext)); + + const decodedRows = yield* Effect.forEach( + legacyRows, + (row) => + decodeProjectionThreadGoalJson(row.goalJson).pipe( + Effect.map((goal) => ({ threadId: row.threadId, goal })), + Effect.mapError( + toPersistenceSqlError("ProjectionThreadGoalRepository.backfill:decodeGoal"), + ), + ), + { concurrency: "unbounded" }, + ); + + yield* Effect.forEach( + decodedRows, + (row) => upsertGoal(row).pipe(Effect.provide(forkContext)), + { concurrency: "unbounded" }, + ); + + yield* sql` + UPDATE projection_threads + SET goal_json = NULL + WHERE goal_json IS NOT NULL + `; + } + } + + yield* ensureGoalTable().pipe(Effect.provide(forkContext)); + yield* runForkMigrations().pipe(Effect.provide(forkContext)); + + const getByThreadId: ProjectionThreadGoalRepositoryShape["getByThreadId"] = (input) => + listGoalsByThreadIds({ threadIds: [input.threadId] }).pipe( + Effect.map((rows) => Option.fromNullishOr(rows[0]?.goal)), + Effect.mapError( + toPersistenceSqlError("ProjectionThreadGoalRepository.getByThreadId:query"), + ), + Effect.provide(forkContext), + ); + + const getByThreadIds: ProjectionThreadGoalRepositoryShape["getByThreadIds"] = (input) => + listGoalsByThreadIds({ threadIds: input.threadIds }).pipe( + Effect.map( + (rows) => + new Map(rows.map((row) => [row.threadId, row.goal] as const)) as ReadonlyMap< + ThreadId, + Schema.Schema.Type + >, + ), + Effect.mapError( + toPersistenceSqlError("ProjectionThreadGoalRepository.getByThreadIds:query"), + ), + Effect.provide(forkContext), + ); + + const upsert: ProjectionThreadGoalRepositoryShape["upsert"] = (input) => + upsertGoal(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadGoalRepository.upsert:query")), + Effect.provide(forkContext), + ); + + const deleteByThreadId: ProjectionThreadGoalRepositoryShape["deleteByThreadId"] = (input) => + deleteGoal(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadGoalRepository.deleteByThreadId:query"), + ), + Effect.provide(forkContext), + ); + + return { + getByThreadId, + getByThreadIds, + upsert, + deleteByThreadId, + } satisfies ProjectionThreadGoalRepositoryShape; + }), +); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 0a8b2e31c..d1ce0dd51 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -11,6 +11,7 @@ import { IsoDateTime, ModelSelection, NonNegativeInt, + OrchestrationThreadGoal, ProjectId, ProviderInteractionMode, RuntimeMode, @@ -54,6 +55,7 @@ export const ProjectionThread = Schema.Struct({ pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, hasActionableProposedPlan: NonNegativeInt, + goal: Schema.optional(Schema.NullOr(OrchestrationThreadGoal)), deletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index c440d26a9..3c69291ad 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -66,6 +66,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, + CodexSessionRuntimeGoalUnsupportedError, CodexSessionRuntimeThreadIdMissingError, describeMcpElicitation, makeCodexSessionRuntime, @@ -2525,12 +2526,29 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); - const compactThread = Effect.fn("compactThread")(function* (threadId: ThreadId) { - const session = yield* requireSession(threadId); - yield* session.runtime.compactThread.pipe( - Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), + const sendGoalRequest: NonNullable = (threadId, request) => + requireSession(threadId).pipe( + Effect.flatMap((session) => { + if (!session.runtime.sendGoalRequest) { + return Effect.fail(new CodexSessionRuntimeGoalUnsupportedError()); + } + return session.runtime.sendGoalRequest(request); + }), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(threadId, "thread/goal", cause), + ), ); - }); + + const compactThread: NonNullable = Effect.fn("compactThread")( + function* (threadId) { + const session = yield* requireSession(threadId); + yield* session.runtime.compactThread.pipe( + Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), + ); + }, + ); const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( @@ -2679,6 +2697,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( sendTurn, compaction: { type: "native", start: compactThread }, interruptTurn, + sendGoalRequest, readThread, rollbackThread, uploadFeedback, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5e6bfe8d4..0be11aa0e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -8,6 +8,7 @@ import { type ProviderApprovalDecision, type ProviderApprovalOption, type ProviderEvent, + type ThreadGoalRequest, type ProviderInteractionMode, type ProviderRequestKind, type ProviderSession, @@ -41,6 +42,14 @@ import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); +const decodeThreadGoalGetResponse = Schema.decodeUnknownEffect( + Schema.Struct({ + goal: Schema.Union([ + EffectCodexSchema.V2ThreadGoalUpdatedNotification__ThreadGoal, + Schema.Null, + ]), + }), +); const PROVIDER = ProviderDriverKind.make("codex"); @@ -199,6 +208,9 @@ export interface CodexSessionRuntimeShape { ) => Effect.Effect; readonly compactThread: Effect.Effect; readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; + readonly sendGoalRequest?: ( + request: ThreadGoalRequest, + ) => Effect.Effect; readonly readThread: Effect.Effect; readonly rollbackThread: ( numTurns: number, @@ -223,6 +235,7 @@ export type CodexSessionRuntimeError = | CodexSessionRuntimePendingApprovalNotFoundError | CodexSessionRuntimePendingUserInputNotFoundError | CodexSessionRuntimeInvalidUserInputAnswersError + | CodexSessionRuntimeGoalUnsupportedError | CodexSessionRuntimeThreadIdMissingError; export class CodexSessionRuntimePendingApprovalNotFoundError extends Schema.TaggedErrorClass()( @@ -269,6 +282,15 @@ export class CodexSessionRuntimeThreadIdMissingError extends Schema.TaggedErrorC } } +export class CodexSessionRuntimeGoalUnsupportedError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeGoalUnsupportedError", + {}, +) { + override get message(): string { + return "Codex session does not support goals."; + } +} + interface PendingApproval { readonly requestId: ApprovalRequestId; readonly jsonRpcId: string; @@ -778,6 +800,7 @@ function readNotificationThreadId(notification: CodexServerNotification): string case "thread/name/updated": case "thread/settings/updated": case "thread/tokenUsage/updated": + case "thread/goal/cleared": case "model/rerouted": case "turn/started": case "hook/started": @@ -811,6 +834,8 @@ function readNotificationThreadId(notification: CodexServerNotification): string case "thread/realtime/error": case "thread/realtime/closed": return notification.params.threadId; + case "thread/goal/updated": + return notification.params.goal.threadId; default: return undefined; } @@ -1047,6 +1072,8 @@ function shouldSuppressChildConversationNotification( method === "thread/name/updated" || method === "thread/settings/updated" || method === "thread/tokenUsage/updated" || + method === "thread/goal/updated" || + method === "thread/goal/cleared" || method === "model/rerouted" || method === "turn/started" || method === "turn/completed" || @@ -2427,6 +2454,66 @@ export const makeCodexSessionRuntime = ( turnId: effectiveTurnId, }); }), + sendGoalRequest: (request) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + switch (request.kind) { + case "status": { + const rawResponse = yield* client.raw.request("thread/goal/get", { + threadId: providerThreadId, + }); + const response = yield* decodeThreadGoalGetResponse(rawResponse).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "thread/goal/get" }, + ), + ), + ); + if (response.goal) { + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "thread/goal/updated", + payload: { + threadId: providerThreadId, + goal: response.goal, + }, + }); + } else { + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "thread/goal/cleared", + payload: { + threadId: providerThreadId, + }, + }); + } + return; + } + case "set": + yield* client.raw.request("thread/goal/set", { + threadId: providerThreadId, + objective: request.objective, + status: "active", + }); + return; + case "control": + if (request.action === "clear") { + yield* client.raw.request("thread/goal/clear", { + threadId: providerThreadId, + }); + return; + } + yield* client.raw.request("thread/goal/set", { + threadId: providerThreadId, + status: request.action === "pause" ? "paused" : "active", + }); + return; + } + }), readThread: Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const response = yield* client.request("thread/read", { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2b2719faa..7d3107af8 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -14,6 +14,7 @@ import { MessageId, ModelSelection, NonNegativeInt, + ProviderGoalRequestInput, ProviderInterruptTurnInput, ProviderRespondToRequestInput, ProviderRespondToUserInputInput, @@ -1673,6 +1674,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); + const sendGoalRequest: ProviderServiceMethod<"sendGoalRequest"> = Effect.fn("sendGoalRequest")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.sendGoalRequest", + schema: ProviderGoalRequestInput, + payload: rawInput, + }); + let metricProvider = "unknown"; + return yield* Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.sendGoalRequest", + allowRecovery: true, + }); + metricProvider = routed.adapter.provider; + if (!routed.adapter.sendGoalRequest) { + return yield* toValidationError( + "ProviderService.sendGoalRequest", + `Provider '${routed.adapter.provider}' does not support goal requests.`, + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "send-goal-request", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.goal_request_kind": input.request.kind, + }); + yield* routed.adapter.sendGoalRequest(routed.threadId, input.request); + yield* analytics.record("provider.goal.requested", { + provider: routed.adapter.provider, + requestKind: input.request.kind, + }); + }).pipe( + withMetrics({ + counter: providerTurnsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "goal-request", + }), + }), + ); + }, + ); + const respondToRequest: ProviderServiceMethod<"respondToRequest"> = Effect.fn("respondToRequest")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -2076,6 +2121,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( sendTurn, compactThread, interruptTurn, + sendGoalRequest, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c9b62fd79..611305de8 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -16,6 +16,7 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ThreadGoalRequest, ProviderUploadFeedbackInput, ProviderUploadFeedbackResult, ThreadId, @@ -93,6 +94,14 @@ export interface ProviderAdapterShape { */ readonly interruptTurn: (threadId: ThreadId, turnId?: TurnId) => Effect.Effect; + /** + * Send a provider-native goal request, when the provider supports goals. + */ + readonly sendGoalRequest?: ( + threadId: ThreadId, + request: ThreadGoalRequest, + ) => Effect.Effect; + /** * Respond to an interactive approval request. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index c189e2916..8c7981740 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -12,6 +12,7 @@ * @module ProviderService */ import type { + ProviderGoalRequestInput, ProviderInterruptTurnInput, ProviderInstanceId, ProviderRespondToRequestInput, @@ -67,6 +68,13 @@ export interface ProviderServiceShape { input: ProviderInterruptTurnInput, ) => Effect.Effect; + /** + * Send a provider-native goal request. + */ + readonly sendGoalRequest?: ( + input: ProviderGoalRequestInput, + ) => Effect.Effect; + /** * Respond to a provider approval request. */ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46396bf1e..e536ebc7e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -320,7 +320,9 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" - | "thread.session-set"; + | "thread.session-set" + | "thread.goal-updated" + | "thread.goal-cleared"; } > { return ( @@ -329,7 +331,9 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || - event.type === "thread.session-set" + event.type === "thread.session-set" || + event.type === "thread.goal-updated" || + event.type === "thread.goal-cleared" ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1415fffab..a3b6d2aa4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -45,6 +45,7 @@ import { submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; +import { parseCodexGoalCommand } from "@t3tools/client-runtime/state/threads"; import { parseScopedThreadKey, scopedThreadKey, @@ -205,7 +206,11 @@ import { GitBranchIcon, Minimize2Icon, PaperclipIcon, + PauseIcon, + PlayIcon, + TargetIcon, WifiOffIcon, + XIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -237,6 +242,7 @@ import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations" import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { useThreadActions } from "../hooks/useThreadActions"; +import { formatGoalStatusToastDescription, goalStatusToastTitle } from "../goalPresentation"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -1351,6 +1357,21 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +function showGoalStatusToast(goal: Thread["goal"]): void { + if (!goal) { + toastManager.add({ + type: "info", + title: "No active goal", + }); + return; + } + toastManager.add({ + type: "info", + title: goalStatusToastTitle(goal), + description: formatGoalStatusToastDescription(goal), + }); +} + const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; /** @@ -1408,6 +1429,9 @@ export default function ChatView(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const requestThreadGoal = useAtomCommand(threadEnvironment.requestGoal, { + reportFailure: false, + }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, @@ -1602,6 +1626,9 @@ export default function ChatView(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [pendingGoalAction, setPendingGoalAction] = useState<"pause" | "resume" | "clear" | null>( + null, + ); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -5568,6 +5595,134 @@ export default function ChatView(props: ChatViewProps) { isUnsnoozing, isUnsettling, ]); + const handleGoalControl = useCallback( + async (action: "pause" | "resume" | "clear") => { + if ( + !isServerThread || + !activeThread || + pendingGoalAction !== null || + isSendBusy || + isConnecting || + threadDetailLoading || + activeEnvironmentUnavailable + ) { + return; + } + setPendingGoalAction(action); + setThreadError(activeThread.id, null); + const result = await requestThreadGoal({ + environmentId, + input: { + threadId: activeThread.id, + request: { kind: "control", action }, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : `Failed to ${action} Goal.`, + ); + } + setPendingGoalAction(null); + }, + [ + activeEnvironmentUnavailable, + activeThread, + environmentId, + isConnecting, + isSendBusy, + isServerThread, + pendingGoalAction, + requestThreadGoal, + setThreadError, + threadDetailLoading, + ], + ); + const goalBannerItem = useMemo(() => { + const goal = activeThread?.goal; + if (!goal || !isServerThread) { + return null; + } + const description = formatGoalStatusToastDescription(goal); + const primaryAction = goal.status === "active" ? "pause" : "resume"; + const controlsDisabled = + pendingGoalAction !== null || + isSendBusy || + isConnecting || + threadDetailLoading || + activeEnvironmentUnavailable; + return { + id: `goal:${activeThread.id}`, + variant: "info", + icon: , + title: goalStatusToastTitle(goal), + description: ( + + {description}} /> + + {description} + + + ), + actions: ( +
+ + void handleGoalControl(primaryAction)} + /> + } + > + {primaryAction === "pause" ? : } + + + {pendingGoalAction === primaryAction + ? primaryAction === "pause" + ? "Pausing Goal..." + : "Resuming Goal..." + : primaryAction === "pause" + ? "Pause Goal" + : "Resume Goal"} + + + + void handleGoalControl("clear")} + /> + } + > + + + + {pendingGoalAction === "clear" ? "Clearing Goal..." : "Clear Goal"} + + +
+ ), + }; + }, [ + activeEnvironmentUnavailable, + activeThread?.goal, + activeThread?.id, + handleGoalControl, + isConnecting, + isSendBusy, + isServerThread, + pendingGoalAction, + threadDetailLoading, + ]); // Session-scoped dismissals, one key per (thread, snapshot). A set rather // than a single slot so dismissing the banner on one thread does not // resurface it on another thread dismissed earlier. @@ -5704,6 +5859,7 @@ export default function ChatView(props: ChatViewProps) { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; + const goalItems = goalBannerItem === null ? [] : [goalBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, @@ -5713,6 +5869,7 @@ export default function ChatView(props: ChatViewProps) { ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, + ...goalItems, ]; } return [ @@ -5761,11 +5918,13 @@ export default function ChatView(props: ChatViewProps) { }, }, ...parkedThreadItems, + ...goalItems, ]; }, [ activeBranchMismatchKey, backgroundLivenessBannerItem, feedbackBannerItems, + goalBannerItem, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, @@ -6387,6 +6546,138 @@ export default function ChatView(props: ChatViewProps) { return; } + const goalSlashCommand = + ctxSelectedProvider === "codex" && + composerImages.length === 0 && + composerFiles.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 + ? parseCodexGoalCommand(trimmed) + : null; + if (goalSlashCommand) { + if (goalSlashCommand.kind === "invalid") { + setThreadError(activeThread.id, goalSlashCommand.message); + return; + } + if (!activeProject) { + setThreadError(activeThread.id, "Choose a project before using /goal."); + return; + } + if (!isServerThread && goalSlashCommand.kind !== "set") { + setThreadError(activeThread.id, "Enter a goal objective to start a thread with /goal."); + return; + } + + sendInFlightRef.current = true; + beginLocalDispatch({ preparingWorktree: false }); + setThreadError(activeThread.id, null); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + + const createdAt = new Date().toISOString(); + const title = + goalSlashCommand.kind === "set" ? truncate(goalSlashCommand.objective) : activeThread.title; + const threadCreateModelSelection = createModelSelection( + ctxSelectedModelSelection.instanceId, + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, + ctxSelectedModelSelection.options, + ); + let failure: AtomCommandResult | null = null; + + if (!isServerThread) { + const createResult = await createThread({ + environmentId, + input: { + threadId: activeThread.id, + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode: sendInteractionMode, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + }); + if (createResult._tag === "Failure") { + failure = createResult; + } + } else if (activeThread.messages.length === 0 && goalSlashCommand.kind === "set") { + const titleResult = await updateThreadMetadata({ + environmentId, + input: { threadId: activeThread.id, title }, + }); + if (titleResult._tag === "Failure") { + failure = titleResult; + } + } + + if (failure === null && isServerThread) { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId: activeThread.id, + createdAt, + ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), + runtimeMode, + interactionMode: sendInteractionMode, + }); + if (settingsResult._tag === "Failure") { + failure = settingsResult; + } + } + + if (failure === null) { + const goalResult = await requestThreadGoal({ + environmentId, + input: { + threadId: activeThread.id, + request: goalSlashCommand, + createdAt, + }, + }); + if (goalResult._tag === "Failure") { + failure = goalResult; + } + } + + if (failure === null) { + if (goalSlashCommand.kind === "status") { + showGoalStatusToast(activeThread.goal); + } + if (!isServerThread) { + await waitForStartedServerThread( + scopeThreadRef(activeThread.environmentId, activeThread.id), + ); + await navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: activeThread.environmentId, + threadId: activeThread.id, + }, + }); + } + } else { + promptRef.current = promptForSend; + setComposerDraftPrompt(composerDraftTarget, promptForSend); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), + prompt: promptForSend, + detectTrigger: true, + }); + if (!isAtomCommandInterrupted(failure)) { + const error = squashAtomCommandFailure(failure); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to send goal command.", + ); + } + } + sendInFlightRef.current = false; + resetLocalDispatch(); + return; + } if ( !directAnnotation && sendInteractionModeEnabled && diff --git a/apps/web/src/goalPresentation.ts b/apps/web/src/goalPresentation.ts new file mode 100644 index 000000000..3f652167c --- /dev/null +++ b/apps/web/src/goalPresentation.ts @@ -0,0 +1,40 @@ +import type { OrchestrationThreadGoal } from "@t3tools/contracts"; + +export function goalStatusLabel(status: OrchestrationThreadGoal["status"]): string { + switch (status) { + case "active": + return "Active"; + case "paused": + return "Paused"; + case "blocked": + return "Blocked"; + case "usageLimited": + return "Usage limited"; + case "budgetLimited": + return "Budget limited"; + case "complete": + return "Complete"; + } +} + +export function goalStatusToastTitle(goal: OrchestrationThreadGoal): string { + return `Goal ${goalStatusLabel(goal.status).toLowerCase()}`; +} + +export function formatGoalDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +} + +export function formatGoalTokens(goal: OrchestrationThreadGoal): string { + const used = goal.tokensUsed.toLocaleString(); + return goal.tokenBudget === null ? used : `${used} / ${goal.tokenBudget.toLocaleString()}`; +} + +export function formatGoalStatusToastDescription(goal: OrchestrationThreadGoal): string { + return `${goal.objective} · ${formatGoalDuration(goal.timeUsedSeconds)} · ${formatGoalTokens(goal)} tokens`; +} diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 3118895ae..b17feacf7 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -53,6 +53,7 @@ export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.resp export type DismissThreadUserInputInput = CommandInput<"thread.user-input.dismiss">; export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; export type StopThreadSessionInput = CommandInput<"thread.session.stop">; +export type RequestThreadGoalInput = CommandInput<"thread.goal.request">; type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; type CommandEffect = Effect.Effect< @@ -354,3 +355,15 @@ export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect createdAt: metadata.createdAt, }); }); + +export const requestThreadGoal: (input: RequestThreadGoalInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.requestThreadGoal", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.goal.request", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 0b3d63fd0..37e356f16 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -1,6 +1,6 @@ import * as Crypto from "effect/Crypto"; import { Atom } from "effect/unstable/reactivity"; -import { WS_METHODS } from "@t3tools/contracts"; +import { type ThreadGoalRequest, WS_METHODS } from "@t3tools/contracts"; import { createAtomCommandScheduler, @@ -15,6 +15,7 @@ import { type RespondToThreadApprovalInput, type RespondToThreadUserInputInput, type DismissThreadUserInputInput, + type RequestThreadGoalInput, type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, @@ -37,6 +38,7 @@ import { respondToThreadApproval, respondToThreadUserInput, dismissThreadUserInput, + requestThreadGoal, revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, @@ -63,6 +65,7 @@ export type { RespondToThreadApprovalInput, RespondToThreadUserInputInput, DismissThreadUserInputInput, + RequestThreadGoalInput, RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, @@ -80,6 +83,65 @@ export type { UpdateThreadMetadataInput, } from "../operations/commands.ts"; +export type CodexGoalCommand = + | ThreadGoalRequest + | { readonly kind: "invalid"; readonly message: string }; + +const GOAL_OBJECTIVE_MAX_LENGTH = 4_000; +const GOAL_COMMAND_USAGE = + "Usage: /goal [status | create | steer | pause | resume | clear | reset]"; + +function invalidGoalObjectiveLength(): CodexGoalCommand { + return { + kind: "invalid", + message: `Goal objective must be ${GOAL_OBJECTIVE_MAX_LENGTH.toLocaleString()} characters or fewer.`, + }; +} + +export function parseCodexGoalCommand(value: string): CodexGoalCommand | null { + const match = /^\/goal(?:\s+([\s\S]*))?$/i.exec(value.trim()); + if (match === null) return null; + + const argument = match[1]?.trim() ?? ""; + if (argument.length === 0 || argument.toLowerCase() === "status") return { kind: "status" }; + + const [rawAction = "", ...rest] = argument.split(/\s+/); + const action = rawAction.toLowerCase(); + const objective = rest.join(" ").trim(); + if (action === "create" || action === "steer") { + if (objective.length === 0) return { kind: "invalid", message: GOAL_COMMAND_USAGE }; + return objective.length > GOAL_OBJECTIVE_MAX_LENGTH + ? invalidGoalObjectiveLength() + : { kind: "set", objective }; + } + if (action === "edit") { + if (objective.length === 0) { + return { + kind: "invalid", + message: "T3 does not open Codex's Goal editor. Use /goal steer .", + }; + } + return objective.length > GOAL_OBJECTIVE_MAX_LENGTH + ? invalidGoalObjectiveLength() + : { kind: "set", objective }; + } + if (action === "pause" || action === "resume") { + return objective.length === 0 + ? { kind: "control", action } + : { kind: "invalid", message: GOAL_COMMAND_USAGE }; + } + if (action === "clear" || action === "reset") { + return objective.length === 0 + ? { kind: "control", action: "clear" } + : { kind: "invalid", message: GOAL_COMMAND_USAGE }; + } + if (action === "status") return { kind: "invalid", message: GOAL_COMMAND_USAGE }; + + return argument.length > GOAL_OBJECTIVE_MAX_LENGTH + ? invalidGoalObjectiveLength() + : { kind: "set", objective: argument }; +} + export function createThreadEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { @@ -210,6 +272,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + requestGoal: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:request-goal", + execute: (input: RequestThreadGoalInput) => requestThreadGoal(input), + scheduler, + concurrency, + }), revertCheckpoint: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:revert-checkpoint", execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input), diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index a3481fdc7..f5b6ba86f 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -480,6 +480,26 @@ export function applyThreadDetailEvent( }, }; + case "thread.goal-updated": + return { + kind: "updated", + thread: { + ...thread, + goal: event.payload.goal, + updatedAt: event.occurredAt, + }, + }; + + case "thread.goal-cleared": + return { + kind: "updated", + thread: { + ...thread, + goal: null, + updatedAt: event.occurredAt, + }, + }; + // ── Proposed plans ────────────────────────────────────────────── case "thread.proposed-plan-upserted": { const proposedPlan = event.payload.proposedPlan; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index da4eac53d..8f20d5a2c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -449,6 +449,27 @@ export const OrchestrationThreadActivity = Schema.Struct({ }); export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; +export const OrchestrationThreadGoalStatus = Schema.Literals([ + "active", + "paused", + "blocked", + "usageLimited", + "budgetLimited", + "complete", +]); +export type OrchestrationThreadGoalStatus = typeof OrchestrationThreadGoalStatus.Type; + +export const OrchestrationThreadGoal = Schema.Struct({ + objective: TrimmedNonEmptyString, + status: OrchestrationThreadGoalStatus, + tokensUsed: NonNegativeInt, + tokenBudget: Schema.NullOr(NonNegativeInt), + timeUsedSeconds: NonNegativeInt, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type OrchestrationThreadGoal = typeof OrchestrationThreadGoal.Type; + const OrchestrationLatestTurnState = Schema.Literals([ "running", "interrupted", @@ -529,6 +550,7 @@ export const OrchestrationThread = Schema.Struct({ // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), + goal: Schema.optional(Schema.NullOr(OrchestrationThreadGoal)), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( Schema.withDecodingDefault(Effect.succeed([])), @@ -594,6 +616,7 @@ export const OrchestrationThreadShell = Schema.Struct({ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), + goal: Schema.optional(Schema.NullOr(OrchestrationThreadGoal)), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, hasPendingUserInput: Schema.Boolean, @@ -1052,6 +1075,21 @@ const ThreadCheckpointRevertCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadGoalRequest = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("status"), + }), + Schema.Struct({ + kind: Schema.Literal("control"), + action: Schema.Literals(["pause", "resume", "clear"]), + }), + Schema.Struct({ + kind: Schema.Literal("set"), + objective: TrimmedNonEmptyString.check(Schema.isMaxLength(4_000)), + }), +]); +export type ThreadGoalRequest = typeof ThreadGoalRequest.Type; + const ThreadSessionStopCommand = Schema.Struct({ type: Schema.Literal("thread.session.stop"), commandId: CommandId, @@ -1065,6 +1103,14 @@ const ThreadSessionStopCommand = Schema.Struct({ onlyIfSettled: Schema.optional(Schema.Boolean), }); +const ThreadGoalRequestCommand = Schema.Struct({ + type: Schema.Literal("thread.goal.request"), + commandId: CommandId, + threadId: ThreadId, + request: ThreadGoalRequest, + createdAt: IsoDateTime, +}); + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, @@ -1091,6 +1137,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputDismissCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadGoalRequestCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -1121,6 +1168,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputDismissCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadGoalRequestCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -1132,6 +1180,21 @@ const ThreadSessionSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadGoalUpdateCommand = Schema.Struct({ + type: Schema.Literal("thread.goal.update"), + commandId: CommandId, + threadId: ThreadId, + goal: OrchestrationThreadGoal, + createdAt: IsoDateTime, +}); + +const ThreadGoalClearCommand = Schema.Struct({ + type: Schema.Literal("thread.goal.clear"), + commandId: CommandId, + threadId: ThreadId, + createdAt: IsoDateTime, +}); + const ThreadMessageAssistantDeltaCommand = Schema.Struct({ type: Schema.Literal("thread.message.assistant.delta"), commandId: CommandId, @@ -1231,6 +1294,8 @@ const ThreadPullRequestSyncCommand = Schema.Struct({ const InternalOrchestrationCommand = Schema.Union([ ThreadAutoSettleCommand, ThreadSessionSetCommand, + ThreadGoalUpdateCommand, + ThreadGoalClearCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, ThreadHistoryImportCommand, @@ -1276,6 +1341,9 @@ export const OrchestrationEventType = Schema.Literals([ "thread.reverted", "thread.session-stop-requested", "thread.session-set", + "thread.goal-requested", + "thread.goal-updated", + "thread.goal-cleared", "thread.proposed-plan-upserted", "thread.turn-diff-completed", "thread.activity-appended", @@ -1500,6 +1568,21 @@ export const ThreadSessionSetPayload = Schema.Struct({ session: OrchestrationSession, }); +export const ThreadGoalRequestedPayload = Schema.Struct({ + threadId: ThreadId, + request: ThreadGoalRequest, + createdAt: IsoDateTime, +}); + +export const ThreadGoalUpdatedPayload = Schema.Struct({ + threadId: ThreadId, + goal: OrchestrationThreadGoal, +}); + +export const ThreadGoalClearedPayload = Schema.Struct({ + threadId: ThreadId, +}); + export const ThreadProposedPlanUpsertedPayload = Schema.Struct({ threadId: ThreadId, proposedPlan: OrchestrationProposedPlan, @@ -1687,6 +1770,21 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.session-set"), payload: ThreadSessionSetPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.goal-requested"), + payload: ThreadGoalRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.goal-updated"), + payload: ThreadGoalUpdatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.goal-cleared"), + payload: ThreadGoalClearedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.proposed-plan-upserted"), diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 3f9570a30..356532026 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -20,6 +20,7 @@ import { ProviderSandboxMode, ProviderUserInputAnswers, RuntimeMode, + ThreadGoalRequest, } from "./orchestration.ts"; import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; @@ -113,6 +114,12 @@ export const ProviderRespondToUserInputInput = Schema.Struct({ }); export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; +export const ProviderGoalRequestInput = Schema.Struct({ + threadId: ThreadId, + request: ThreadGoalRequest, +}); +export type ProviderGoalRequestInput = typeof ProviderGoalRequestInput.Type; + export const ProviderUploadFeedbackInput = Schema.Struct({ threadId: ThreadId, reason: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index af1baac74..546a6d57e 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -157,6 +157,8 @@ const ProviderRuntimeEventType = Schema.Literals([ "thread.state.changed", "thread.metadata.updated", "thread.token-usage.updated", + "thread.goal.updated", + "thread.goal.cleared", "thread.realtime.started", "thread.realtime.item-added", "thread.realtime.audio.delta", @@ -209,6 +211,8 @@ const ThreadStartedType = Schema.Literal("thread.started"); const ThreadStateChangedType = Schema.Literal("thread.state.changed"); const ThreadMetadataUpdatedType = Schema.Literal("thread.metadata.updated"); const ThreadTokenUsageUpdatedType = Schema.Literal("thread.token-usage.updated"); +const ThreadGoalUpdatedType = Schema.Literal("thread.goal.updated"); +const ThreadGoalClearedType = Schema.Literal("thread.goal.cleared"); const ThreadRealtimeStartedType = Schema.Literal("thread.realtime.started"); const ThreadRealtimeItemAddedType = Schema.Literal("thread.realtime.item-added"); const ThreadRealtimeAudioDeltaType = Schema.Literal("thread.realtime.audio.delta"); @@ -337,6 +341,32 @@ const ThreadTokenUsageUpdatedPayload = Schema.Struct({ }); export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type; +export const ProviderRuntimeThreadGoalStatus = Schema.Literals([ + "active", + "paused", + "blocked", + "usageLimited", + "budgetLimited", + "complete", +]); +export type ProviderRuntimeThreadGoalStatus = typeof ProviderRuntimeThreadGoalStatus.Type; + +const ProviderRuntimeThreadGoalUpdatedPayload = Schema.Struct({ + objective: TrimmedNonEmptyStringSchema, + status: ProviderRuntimeThreadGoalStatus, + tokensUsed: NonNegativeInt, + tokenBudget: Schema.NullOr(NonNegativeInt), + timeUsedSeconds: NonNegativeInt, + createdAtEpochMsOrSeconds: Schema.Number, + updatedAtEpochMsOrSeconds: Schema.Number, +}); +export type ProviderRuntimeThreadGoalUpdatedPayload = + typeof ProviderRuntimeThreadGoalUpdatedPayload.Type; + +const ProviderRuntimeThreadGoalClearedPayload = Schema.Struct({}); +export type ProviderRuntimeThreadGoalClearedPayload = + typeof ProviderRuntimeThreadGoalClearedPayload.Type; + const ThreadRealtimeStartedPayload = Schema.Struct({ realtimeSessionId: Schema.optional(TrimmedNonEmptyStringSchema), }); @@ -927,6 +957,22 @@ const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({ export type ProviderRuntimeThreadTokenUsageUpdatedEvent = typeof ProviderRuntimeThreadTokenUsageUpdatedEvent.Type; +const ProviderRuntimeThreadGoalUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadGoalUpdatedType, + payload: ProviderRuntimeThreadGoalUpdatedPayload, +}); +export type ProviderRuntimeThreadGoalUpdatedEvent = + typeof ProviderRuntimeThreadGoalUpdatedEvent.Type; + +const ProviderRuntimeThreadGoalClearedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadGoalClearedType, + payload: ProviderRuntimeThreadGoalClearedPayload, +}); +export type ProviderRuntimeThreadGoalClearedEvent = + typeof ProviderRuntimeThreadGoalClearedEvent.Type; + const ProviderRuntimeThreadRealtimeStartedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: ThreadRealtimeStartedType, @@ -1235,6 +1281,8 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeThreadStateChangedEvent, ProviderRuntimeThreadMetadataUpdatedEvent, ProviderRuntimeThreadTokenUsageUpdatedEvent, + ProviderRuntimeThreadGoalUpdatedEvent, + ProviderRuntimeThreadGoalClearedEvent, ProviderRuntimeThreadRealtimeStartedEvent, ProviderRuntimeThreadRealtimeItemAddedEvent, ProviderRuntimeThreadRealtimeAudioDeltaEvent, From cec303577a688b8a5921ddb539be92c9e871792a Mon Sep 17 00:00:00 2001 From: Taras Date: Fri, 4 Sep 2026 19:41:28 +0300 Subject: [PATCH 6/7] feat(desktop,web): restore maintained client features --- apps/desktop/src/app/DesktopApp.ts | 69 ++++-- .../app/DesktopEarlyElectronStartup.test.ts | 7 + .../src/app/DesktopEarlyElectronStartup.ts | 15 +- apps/desktop/src/app/DesktopEnvironment.ts | 30 ++- .../src/app/DesktopPreReadyPlatform.ts | 2 + .../src/backend/DesktopBackendManager.ts | 2 +- .../DesktopLocalEnvironmentAuth.test.ts | 1 + .../backend/DesktopLocalEnvironmentAuth.ts | 23 +- .../src/backend/DesktopServerExposure.test.ts | 1 + apps/desktop/src/electron/ElectronProtocol.ts | 118 ++++++++- .../src/electron/ElectronUpdater.test.ts | 36 ++- apps/desktop/src/electron/ElectronUpdater.ts | 224 +++++++++++------- .../src/electron/installUnsignedMacUpdate.ts | 139 +++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/localBackend.ts | 21 ++ apps/desktop/src/main.ts | 2 +- apps/desktop/src/preload.ts | 2 + .../src/settings/DesktopAppSettings.test.ts | 9 +- .../src/settings/DesktopAppSettings.ts | 27 +++ .../settings/DesktopClientSettings.test.ts | 1 + apps/desktop/src/updates/DesktopUpdates.ts | 2 +- apps/desktop/src/updates/updateChannels.ts | 6 + .../desktop/src/updates/updatesTestHarness.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 1 + apps/desktop/src/window/DesktopWindow.ts | 13 +- .../features/threads/ThreadDetailScreen.tsx | 2 +- .../src/state/use-thread-composer-state.ts | 3 +- .../Layers/ProviderCommandReactor.ts | 12 + .../Layers/ProviderRuntimeIngestion.ts | 6 + .../Layers/ProjectLaunchEnvLive.ts | 106 +++++++++ .../Layers/ProjectLaunchEnvTest.ts | 167 +++++++++++++ .../Services/ProjectLaunchEnv.test.ts | 144 +++++++++++ .../Services/ProjectLaunchEnv.ts | 45 ++++ .../Services/ProjectLaunchEnvErrors.ts | 29 +++ .../projectLaunchEnv/projectLaunchEnv.test.ts | 48 ++++ .../projectLaunchEnv/projectLaunchEnvUtils.ts | 37 +++ .../src/provider/Drivers/AntigravityDriver.ts | 19 +- .../src/provider/Layers/AntigravityAdapter.ts | 8 +- .../src/provider/Layers/ClaudeAdapter.ts | 4 +- .../src/provider/Layers/CodexAdapter.ts | 29 ++- .../src/provider/Layers/CursorAdapter.ts | 3 +- .../server/src/provider/Layers/GrokAdapter.ts | 7 +- .../src/provider/Layers/OpenCodeAdapter.ts | 7 +- .../src/provider/Layers/ProviderService.ts | 16 ++ .../provider/ProviderInstanceEnvironment.ts | 22 +- apps/server/src/server.ts | 13 +- apps/server/src/terminal/Manager.ts | 98 +++++++- apps/web/src/branding.logic.ts | 9 +- apps/web/src/branding.ts | 12 +- apps/web/src/components/ChatView.tsx | 21 +- apps/web/src/components/Sidebar.tsx | 152 +++++++++++- .../src/components/SidebarStageBackdrop.tsx | 4 +- .../src/components/ThreadTerminalDrawer.tsx | 12 +- .../settings/ConnectionsSettings.tsx | 108 ++++++++- .../settings/KeybindingsSettings.logic.ts | 2 +- .../components/settings/SettingsPanels.tsx | 70 +++++- .../components/sidebar/SidebarUpdatePill.tsx | 7 +- .../sidebar/useSidebarActiveThreadScroll.ts | 76 ++++++ apps/web/src/environments/primary/auth.ts | 8 +- apps/web/src/hostedPairing.ts | 18 +- apps/web/src/localApi.test.ts | 7 +- apps/web/src/localApi.ts | 36 ++- apps/web/src/uiStateStore.test.ts | 2 + apps/web/src/uiStateStore.ts | 21 +- apps/web/src/versionSkew.ts | 11 +- docs/operations/release.md | 3 + packages/contracts/src/ipc.ts | 9 +- packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/provider.ts | 9 + packages/contracts/src/settings.ts | 8 + packages/contracts/src/terminal.ts | 7 + packages/shared/package.json | 4 + packages/shared/src/keybindings.ts | 1 + packages/shared/src/projectLaunchEnv.ts | 52 ++++ packages/ssh/src/command.ts | 5 +- 76 files changed, 2037 insertions(+), 218 deletions(-) create mode 100644 apps/desktop/src/electron/installUnsignedMacUpdate.ts create mode 100644 apps/desktop/src/ipc/methods/localBackend.ts create mode 100644 apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvLive.ts create mode 100644 apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvTest.ts create mode 100644 apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.test.ts create mode 100644 apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.ts create mode 100644 apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnvErrors.ts create mode 100644 apps/server/src/projectLaunchEnv/projectLaunchEnv.test.ts create mode 100644 apps/server/src/projectLaunchEnv/projectLaunchEnvUtils.ts create mode 100644 apps/web/src/components/sidebar/useSidebarActiveThreadScroll.ts create mode 100644 packages/shared/src/projectLaunchEnv.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d21eefd65..3a61a9e0e 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -142,17 +142,41 @@ const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); 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 appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); + const settings = yield* desktopSettings.get; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const desktopScheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + if (!settings.localBackendEnabled) { + if (environment.isDevelopment) { + const devServerUrl = Option.getOrThrow(environment.devServerUrl); + yield* electronProtocol.registerDesktopProtocol({ + scheme: desktopScheme, + targetOrigin: devServerUrl, + backendOrigin: devServerUrl, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + } else { + yield* electronProtocol.registerDesktopFileProtocol({ + scheme: desktopScheme, + rendererRootPath: environment.rendererRootPath, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + } + yield* logBootstrapInfo("bootstrap local backend disabled"); + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMain; + } + return; + } + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } @@ -169,24 +193,28 @@ 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, }); } + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; 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, - }); + if (environment.isDevelopment) { + yield* electronProtocol.registerDesktopProtocol({ + scheme: desktopScheme, + targetOrigin: Option.getOrThrow(environment.devServerUrl), + backendOrigin: backendConfig.httpBaseUrl, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + } else { + yield* electronProtocol.registerDesktopFileProtocol({ + scheme: desktopScheme, + rendererRootPath: environment.rendererRootPath, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + } yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); @@ -207,6 +235,9 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; // 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. @@ -216,6 +247,14 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + const primaryConfig = yield* primaryBackend.currentConfig; + if ( + Option.isSome(primaryConfig) && + Option.isNone(primaryConfig.value.preflightFailure) && + !(yield* Ref.get(state.quitting)) + ) { + yield* desktopWindow.createMain; + } yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b7647b5cc..0f2c2937a 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -12,6 +12,7 @@ describe("DesktopEarlyElectronStartup", () => { it("reads the persisted linux password-store preference before Electron is ready", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/home/user/.t3-test" }, homeDirectory: "/home/user", joinPath, @@ -26,6 +27,7 @@ describe("DesktopEarlyElectronStartup", () => { it("accepts JSONC in the early desktop settings file", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/home/user/.t3-test" }, homeDirectory: "/home/user", joinPath, @@ -40,6 +42,7 @@ describe("DesktopEarlyElectronStartup", () => { it("falls back to auto when the early settings document is missing or invalid", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: {}, homeDirectory: "/home/user", joinPath, @@ -53,6 +56,7 @@ describe("DesktopEarlyElectronStartup", () => { it("preserves absolute root paths when resolving early settings", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/" }, homeDirectory: "/home/user", joinPath, @@ -67,6 +71,7 @@ describe("DesktopEarlyElectronStartup", () => { it("resolves the early linux Electron switches", () => { const options = resolveEarlyLinuxElectronOptions({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/home/user/.t3-test", XDG_CURRENT_DESKTOP: "niri", @@ -88,6 +93,7 @@ describe("DesktopEarlyElectronStartup", () => { it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", }, @@ -104,6 +110,7 @@ describe("DesktopEarlyElectronStartup", () => { it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: " ", VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 3e11d7961..78cbe3174 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -9,6 +9,7 @@ import { type LinuxPasswordStoreSwitch, type LinuxPasswordStorePreference, } from "../linuxSecretStorage.ts"; +import { isCanaryDesktopVersion } from "../updates/updateChannels.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir, @@ -16,6 +17,7 @@ import { } from "./DesktopStatePaths.ts"; interface EarlyDesktopSettingsInput { + readonly appVersion: string; readonly env: NodeJS.ProcessEnv; readonly homeDirectory: string; readonly joinPath: JoinPath; @@ -44,11 +46,7 @@ const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJso const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean => trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null; -function resolveEarlyDesktopSettingsPath(input: { - readonly env: NodeJS.ProcessEnv; - readonly homeDirectory: string; - readonly joinPath: JoinPath; -}): string { +function resolveEarlyDesktopSettingsPath(input: EarlyDesktopSettingsInput): string { const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME); const baseDir = resolveDesktopBaseDir({ homeDirectory: input.homeDirectory, @@ -80,8 +78,13 @@ export function resolveEarlyLinuxElectronOptions( input: EarlyLinuxElectronOptionsInput, ): EarlyLinuxElectronOptions { const preference = resolveEarlyLinuxPasswordStorePreference(input); + const isDevelopment = isDevelopmentEnvironment(input.env); return { - linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + linuxWmClass: isDevelopment + ? "t3code-dev" + : isCanaryDesktopVersion(input.appVersion) + ? "t3code-canary" + : "t3code", passwordStore: resolveLinuxPasswordStoreSwitch({ preference, env: input.env, diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 4583e5124..2bd692fb5 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -14,7 +14,7 @@ import * as Path from "effect/Path"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; -import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import { isCanaryDesktopVersion, isNightlyDesktopVersion } from "../updates/updateChannels.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -61,6 +61,7 @@ export class DesktopEnvironment extends Context.Service< readonly serverRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; + readonly rendererRootPath: string; readonly preloadPath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; @@ -95,6 +96,7 @@ function resolveDesktopAppStageLabel(input: { return "Dev"; } + if (isCanaryDesktopVersion(input.appVersion)) return "Canary"; return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; } @@ -148,6 +150,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( const homeDirectory = input.homeDirectory; const devServerUrl = config.devServerUrl; const isDevelopment = Option.isSome(devServerUrl); + const isCanary = !isDevelopment && isCanaryDesktopVersion(input.appVersion); const appDataDirectory = input.platform === "win32" ? Option.getOrElse(config.appDataDirectory, () => @@ -172,14 +175,22 @@ const make = Effect.fn("desktop.environment.make")(function* ( appVersion: input.appVersion, }); const displayName = branding.displayName; - const stateDir = resolveDesktopStateDir({ + const desktopSettingsDir = resolveDesktopStateDir({ baseDir, isDevelopment, joinPath: path.join, t3Home: config.t3Home, }); - const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const stateDir = isCanary ? path.join(baseDir, "canary") : desktopSettingsDir; + const appIdentitySuffix = isDevelopment ? "dev" : isCanary ? "canary" : null; + const userDataDirName = appIdentitySuffix === null ? "t3code" : `t3code-${appIdentitySuffix}`; + const legacyUserDataDirName = isDevelopment + ? "T3 Code (Dev)" + : isCanary + ? "T3 Code (Canary)" + : "T3 Code (Alpha)"; + const appUserModelId = + appIdentitySuffix === null ? "com.t3tools.t3code" : `com.t3tools.t3code.${appIdentitySuffix}`; const linuxApplicationsDir = path.join( Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), "applications", @@ -200,7 +211,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( appDataDirectory, baseDir, stateDir, - desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), + desktopSettingsPath: path.join(desktopSettingsDir, "desktop-settings.json"), clientSettingsPath: path.join(stateDir, "client-settings.json"), savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), @@ -211,6 +222,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( serverRoot, backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, + rendererRootPath: path.join(serverRoot, "apps/server/dist/client"), preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged ? path.join(resourcesPath, "app-update.yml") @@ -223,11 +235,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( otlpExportIntervalMs: config.otlpExportIntervalMs, branding, displayName, - appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => - isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", - ), - linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", - linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => appUserModelId), + linuxDesktopEntryName: `${userDataDirName}.desktop`, + linuxWmClass: userDataDirName, linuxApplicationsDir, appImagePath: config.appImagePath, userDataDirName, diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 718f54115..df808a1fa 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -32,6 +32,8 @@ function readCommandLineSwitchValue( export const resolveEarlyLinuxElectronOptionsFromProcess = (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + appVersion: + typeof Electron.app.getVersion === "function" ? Electron.app.getVersion() : "0.0.0", env: process.env, homeDirectory: NodeOS.homedir(), joinPath: NodePath.posix.join, diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 436c0c08e..77b76aa0f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -15,7 +15,7 @@ // so each instance can resolve its own start config — the primary wires // `configuration.resolvePrimary`, the WSL orchestrator wires a // `configuration.resolveWsl({ port, distro })` closure. -// - onReady / onShutdown drive UI side effects (window auto-open, +// - onReady / onShutdown drive UI side effects (window recovery, // readiness latch) only for instances that want them — the primary's // spec passes the window's handleBackendReady/handleBackendNotReady, // other pool instances pass nothing. diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e7a58baef..6d4a20275 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -61,6 +61,7 @@ describe("DesktopLocalEnvironmentAuth", () => { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), currentConfig: Effect.succeed(Option.some(config)), + waitForReady: () => Effect.succeed(true), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 201492f0e..c2d86cb3a 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -1,6 +1,7 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -11,6 +12,8 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; +const BACKEND_READY_TIMEOUT = Duration.minutes(1); + export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass()( "DesktopLocalEnvironmentAuthBackendNotConfiguredError", {}, @@ -29,8 +32,18 @@ export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.Tag } } +export class DesktopLocalEnvironmentAuthBackendNotReadyError extends Schema.TaggedErrorClass()( + "DesktopLocalEnvironmentAuthBackendNotReadyError", + { timeoutMs: Schema.Number }, +) { + override get message(): string { + return `Local backend did not become ready within ${this.timeoutMs}ms.`; + } +} + export const DesktopLocalEnvironmentAuthError = Schema.Union([ DesktopLocalEnvironmentAuthBackendNotConfiguredError, + DesktopLocalEnvironmentAuthBackendNotReadyError, DesktopLocalEnvironmentAuthSessionBootstrapError, ]); export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type; @@ -58,7 +71,10 @@ export const make = Effect.gen(function* () { const instances = yield* pool.list; const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); - const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; + if (primary === undefined) { + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); + } + const configOption = yield* primary.currentConfig; if (Option.isNone(configOption)) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } @@ -67,6 +83,11 @@ export const make = Effect.gen(function* () { if (!credential) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } + if (!(yield* primary.waitForReady(BACKEND_READY_TIMEOUT))) { + return yield* new DesktopLocalEnvironmentAuthBackendNotReadyError({ + timeoutMs: Duration.toMillis(BACKEND_READY_TIMEOUT), + }); + } const session = yield* bootstrapRemoteBearerSession({ httpBaseUrl: config.httpBaseUrl.href, credential, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index eb0becee0..f68a6681c 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setLocalBackendEnabled: () => Effect.die("unexpected local backend toggle"), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index fabd598d7..6c3661759 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,7 +1,12 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodePath from "@effect/platform-node/NodePath"; 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 Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -55,16 +60,27 @@ export interface DesktopProtocolRegistrationInput { readonly clerkFrontendApiHostname: string | undefined; } +export interface DesktopFileProtocolRegistrationInput { + readonly scheme: string; + readonly rendererRootPath: string; + readonly clerkFrontendApiHostname: string | undefined; +} + export class ElectronProtocol extends Context.Service< ElectronProtocol, { readonly registerDesktopProtocol: ( input: DesktopProtocolRegistrationInput, ) => Effect.Effect; + readonly registerDesktopFileProtocol: ( + input: DesktopFileProtocolRegistrationInput, + ) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronProtocol") {} -export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { +export function makeDesktopContentSecurityPolicy( + input: DesktopProtocolRegistrationInput | DesktopFileProtocolRegistrationInput, +): string { const clerkOrigin = input.clerkFrontendApiHostname ? `https://${input.clerkFrontendApiHostname}` : undefined; @@ -185,6 +201,71 @@ async function proxyRequest( return withContentSecurityPolicy(response, contentSecurityPolicy); } +async function resolveRendererFilePath( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + rootPath: string, + pathname: string, +): Promise { + let decodedPath: string; + try { + decodedPath = decodeURIComponent(pathname).replaceAll("\\", "/"); + } catch { + return null; + } + + const requestedPath = decodedPath.replace(/^\/+/, ""); + const filePath = path.resolve(rootPath, requestedPath); + const relativeToRoot = path.relative(rootPath, filePath); + if ( + relativeToRoot === ".." || + relativeToRoot.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeToRoot) + ) { + return null; + } + + if (requestedPath.length > 0) { + const fileInfo = await Effect.runPromise(fileSystem.stat(filePath).pipe(Effect.option)); + if (Option.isSome(fileInfo) && fileInfo.value.type === "File") { + return filePath; + } + } + + return path.join(rootPath, "index.html"); +} + +async function serveRendererFile( + request: Request, + fileSystem: FileSystem.FileSystem, + path: Path.Path, + rendererRootPath: string, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return new Response(null, { status: 404 }); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response(null, { status: 405 }); + } + + const filePath = await resolveRendererFilePath( + fileSystem, + path, + rendererRootPath, + requestUrl.pathname, + ); + if (filePath === null) { + return new Response(null, { status: 404 }); + } + const fileUrl = Effect.runSync(path.toFileUrl(filePath)); + const response = await Electron.net.fetch(fileUrl.href, { + method: request.method, + }); + return withContentSecurityPolicy(response, contentSecurityPolicy); +} + const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const; async function fetchWithTransientRetry(url: string, init: RequestInit): Promise { @@ -206,6 +287,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise< } export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const registered = yield* Ref.make(false); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( @@ -236,7 +319,36 @@ export const make = Effect.gen(function* () { }, ); - return ElectronProtocol.of({ registerDesktopProtocol }); -}); + const registerDesktopFileProtocol = Effect.fn( + "desktop.electron.protocol.registerDesktopFileProtocol", + )(function* (input: DesktopFileProtocolRegistrationInput) { + if (yield* Ref.get(registered)) return; + + const contentSecurityPolicy = makeDesktopContentSecurityPolicy(input); + const rendererRootPath = path.resolve(input.rendererRootPath); + + yield* Effect.acquireRelease( + Effect.try({ + try: () => { + Electron.protocol.handle(input.scheme, (request) => + serveRendererFile(request, fileSystem, path, rendererRootPath, contentSecurityPolicy), + ); + }, + catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), + }).pipe(Effect.andThen(Ref.set(registered, true))), + () => + Effect.try({ + try: () => Electron.protocol.unhandle(input.scheme), + catch: (cause) => + new ElectronProtocolUnregistrationError({ + scheme: input.scheme, + cause, + }), + }).pipe(Effect.andThen(Ref.set(registered, false)), Effect.orDie), + ); + }); + + return ElectronProtocol.of({ registerDesktopProtocol, registerDesktopFileProtocol }); +}).pipe(Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer))); export const layer = Layer.effect(ElectronProtocol, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 1e005d26f..3ec48a9e5 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,9 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; -const { autoUpdaterMock } = vi.hoisted(() => ({ - autoUpdaterMock: { +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +const { autoUpdaterMock, updaterConstructorMock } = vi.hoisted(() => { + const autoUpdaterMock = { allowDowngrade: false, allowPrerelease: false, autoDownload: true, @@ -17,15 +21,25 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ quitAndInstall: vi.fn(), removeListener: vi.fn(), setFeedURL: vi.fn(), - }, -})); + }; + const updaterConstructorMock = vi.fn(function () { + return autoUpdaterMock; + }); + return { autoUpdaterMock, updaterConstructorMock }; +}); vi.mock("electron-updater", () => ({ - autoUpdater: autoUpdaterMock, + AppImageUpdater: updaterConstructorMock, + MacUpdater: updaterConstructorMock, + NsisUpdater: updaterConstructorMock, })); import * as ElectronUpdater from "./ElectronUpdater.ts"; +const updaterLayer = ElectronUpdater.layer.pipe( + Layer.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "linux"))), +); + describe("ElectronUpdater", () => { beforeEach(() => { autoUpdaterMock.allowDowngrade = false; @@ -56,9 +70,9 @@ describe("ElectronUpdater", () => { }), ); - assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); + assert.deepEqual(autoUpdaterMock.on.mock.calls.at(-1), ["update-available", listener]); assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("wraps rejected update checks in the method-specific typed error", () => @@ -75,7 +89,7 @@ describe("ElectronUpdater", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("preserves the execution-time channel on download failures", () => @@ -95,7 +109,7 @@ describe("ElectronUpdater", () => { "Electron updater failed to download the update on channel nightly.", ); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("sets full changelog mode", () => @@ -107,7 +121,7 @@ describe("ElectronUpdater", () => { yield* updater.setFullChangelog(false); assert.equal(autoUpdaterMock.fullChangelog, false); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); it.effect("preserves quit-and-install flags and the execution-time channel", () => @@ -134,6 +148,6 @@ describe("ElectronUpdater", () => { ); assert.notInclude(error.message, cause.message); assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }).pipe(Effect.provide(updaterLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 8e044de65..40fe87e4e 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -3,12 +3,33 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as NodeServices from "@effect/platform-node/NodeServices"; -import { autoUpdater } from "electron-updater"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + AppImageUpdater, + MacUpdater, + NsisUpdater, + type AppUpdater, + type UpdateDownloadedEvent, +} from "electron-updater"; -type AutoUpdater = typeof autoUpdater; +import { makeInstallUnsignedMacUpdate } from "./installUnsignedMacUpdate.ts"; -export type ElectronUpdaterFeedUrl = Parameters[0]; +export type ElectronUpdaterFeedUrl = Parameters[0]; + +function createUpdater(platform: NodeJS.Platform): AppUpdater { + switch (platform) { + case "linux": + return new AppImageUpdater(); + case "darwin": + return new MacUpdater(); + case "win32": + return new NsisUpdater(); + default: + throw new Error(`Unsupported desktop update platform: ${platform}`); + } +} export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", @@ -80,92 +101,125 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { - autoUpdater.setFeedURL(options); - return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { - autoUpdater.autoDownload = value; - return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { - autoUpdater.autoInstallOnAppQuit = value; - return Effect.void; - }), - setChannel: (channel) => - Effect.suspend(() => { - autoUpdater.channel = channel; - return Effect.void; - }), - setAllowPrerelease: (value) => - Effect.suspend(() => { - autoUpdater.allowPrerelease = value; - return Effect.void; - }), - allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { - autoUpdater.allowDowngrade = value; - return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { - autoUpdater.fullChangelog = value; - return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; +export const make = Effect.gen(function* () { + const installUnsignedMacUpdate = yield* makeInstallUnsignedMacUpdate(); + const platform = yield* HostProcessPlatform; + const updater = createUpdater(platform); + let downloadedUpdatePath: string | undefined; + updater.on("update-downloaded", (event: UpdateDownloadedEvent) => { + downloadedUpdatePath = event.downloadedFile; + }); + + return ElectronUpdater.of({ + setFeedURL: (options) => + Effect.suspend(() => { + updater.setFeedURL(options); + return Effect.void; + }), + setAutoDownload: (value) => + Effect.suspend(() => { + updater.autoDownload = value; + return Effect.void; + }), + setAutoInstallOnAppQuit: (value) => + Effect.suspend(() => { + updater.autoInstallOnAppQuit = value; + return Effect.void; + }), + setChannel: (channel) => + Effect.suspend(() => { + updater.channel = channel; + return Effect.void; + }), + setAllowPrerelease: (value) => + Effect.suspend(() => { + updater.allowPrerelease = value; + return Effect.void; + }), + allowDowngrade: Effect.sync(() => updater.allowDowngrade), + setAllowDowngrade: (value) => + Effect.suspend(() => { + updater.allowDowngrade = value; + return Effect.void; + }), + setFullChangelog: (value) => + Effect.suspend(() => { + updater.fullChangelog = value; + return Effect.void; + }), + setDisableDifferentialDownload: (value) => + Effect.suspend(() => { + updater.disableDifferentialDownload = value; + return Effect.void; + }), + checkForUpdates: Effect.suspend(() => { + const channel = updater.channel; + return Effect.tryPromise({ + try: () => updater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), + }).pipe(Effect.asVoid); }), - checkForUpdates: Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.tryPromise({ - try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), - }).pipe(Effect.asVoid); - }), - downloadUpdate: Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.tryPromise({ - try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), - }).pipe(Effect.asVoid); - }), - quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.try({ - try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => - new ElectronUpdaterQuitAndInstallError({ - channel, - isSilent, - isForceRunAfter, - cause, - }), - }); + downloadUpdate: Effect.suspend(() => { + const channel = updater.channel; + return Effect.tryPromise({ + try: () => updater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), + }).pipe(Effect.asVoid); }), - on: (eventName, listener) => { - const eventTarget = autoUpdater as unknown as { - on: (eventName: string, listener: (...args: Array) => void) => void; - removeListener: (eventName: string, listener: (...args: Array) => void) => void; - }; - const untypedListener = listener as unknown as (...args: Array) => void; - return Effect.acquireRelease( - Effect.sync(() => { - eventTarget.on(eventName, untypedListener); + quitAndInstall: ({ isSilent, isForceRunAfter }) => + Effect.suspend(() => { + const channel = updater.channel; + if (platform === "darwin") { + if (downloadedUpdatePath === undefined) { + return Effect.fail( + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause: new Error("Downloaded macOS update path is unavailable."), + }), + ); + } + return installUnsignedMacUpdate(downloadedUpdatePath).pipe( + Effect.mapError( + (cause) => + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause, + }), + ), + ); + } + return Effect.try({ + try: () => updater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause, + }), + }); }), - () => + on: (eventName, listener) => { + const eventTarget = updater as unknown as { + on: (eventName: string, listener: (...args: Array) => void) => void; + removeListener: (eventName: string, listener: (...args: Array) => void) => void; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( Effect.sync(() => { - eventTarget.removeListener(eventName, untypedListener); + eventTarget.on(eventName, untypedListener); }), - ).pipe(Effect.asVoid); - }, + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); + }, + }); }); -export const layer = Layer.succeed(ElectronUpdater, make); +export const layer = Layer.effect(ElectronUpdater, make).pipe(Layer.provide(NodeServices.layer)); diff --git a/apps/desktop/src/electron/installUnsignedMacUpdate.ts b/apps/desktop/src/electron/installUnsignedMacUpdate.ts new file mode 100644 index 000000000..2bb591c43 --- /dev/null +++ b/apps/desktop/src/electron/installUnsignedMacUpdate.ts @@ -0,0 +1,139 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Electron from "electron"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +const INSTALL_HELPER = `#!/bin/sh +set -eu + +reopen_bundle() { + reopen_target="$1" + reopen_elevated="$2" + + if [ "$reopen_elevated" = "true" ]; then + console_uid="$(/usr/bin/stat -f '%u' /dev/console)" + console_user="$(/usr/bin/id -nu "$console_uid")" + /usr/bin/sudo -u "$console_user" /bin/launchctl asuser "$console_uid" /usr/bin/open "$reopen_target" + return + fi + + /usr/bin/open "$reopen_target" +} + +replace_bundle() { + replace_candidate="$1" + replace_target="$2" + replace_elevated="$3" + replace_previous="\${replace_target}.previous" + + /bin/rm -rf "$replace_previous" + if ! /bin/mv "$replace_target" "$replace_previous"; then + reopen_bundle "$replace_target" "$replace_elevated" || true + return 1 + fi + + if ! /bin/mv "$replace_candidate" "$replace_target"; then + /bin/mv "$replace_previous" "$replace_target" + reopen_bundle "$replace_target" "$replace_elevated" || true + return 1 + fi + + if reopen_bundle "$replace_target" "$replace_elevated"; then + /bin/rm -rf "$replace_previous" + return 0 + fi + + /bin/rm -rf "$replace_target" + /bin/mv "$replace_previous" "$replace_target" + reopen_bundle "$replace_target" "$replace_elevated" || true + return 1 +} + +if [ "\${1:-}" = "--replace-elevated" ]; then + shift + replace_bundle "$1" "$2" true + exit $? +fi + +running_pid="$1" +archive="$2" +target="$3" +helper_dir="$(/usr/bin/dirname "$0")" +stage="$(/usr/bin/mktemp -d "\${TMPDIR:-/tmp}/t3code-update.XXXXXX")" + +cleanup() { + /bin/rm -rf "$stage" + /bin/rm -f "$0" + /bin/rmdir "$helper_dir" 2>/dev/null || true +} +trap cleanup EXIT + +while /bin/kill -0 "$running_pid" 2>/dev/null; do + /bin/sleep 1 +done + +if ! /usr/bin/ditto -x -k "$archive" "$stage"; then + /usr/bin/open "$target" || true + exit 1 +fi + +candidate="$(/usr/bin/find "$stage" -type d -name '*.app' -prune -print | /usr/bin/head -n 1)" +if [ -z "$candidate" ]; then + /usr/bin/open "$target" || true + exit 1 +fi + +/usr/bin/xattr -rd com.apple.quarantine "$candidate" 2>/dev/null || true + +target_parent="$(/usr/bin/dirname "$target")" +if [ -w "$target_parent" ]; then + replace_bundle "$candidate" "$target" false + exit $? +fi + +/usr/bin/osascript - "$0" "$candidate" "$target" <<'APPLESCRIPT' +on run argv + set helperPath to item 1 of argv + set candidatePath to item 2 of argv + set targetPath to item 3 of argv + do shell script "/bin/sh " & quoted form of helperPath & " --replace-elevated " & quoted form of candidatePath & " " & quoted form of targetPath with administrator privileges +end run +APPLESCRIPT +`; + +export const makeInstallUnsignedMacUpdate = Effect.fn("makeInstallUnsignedMacUpdate")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + return Effect.fn("installUnsignedMacUpdate")(function* (archivePath: string) { + const executablePath = Electron.app.getPath("exe"); + const targetBundlePath = path.dirname(path.dirname(path.dirname(executablePath))); + const helperDirectory = yield* fileSystem.makeTempDirectory({ + directory: Electron.app.getPath("temp"), + prefix: "t3code-mac-update-", + }); + const helperPath = path.join(helperDirectory, "install-update.sh"); + yield* fileSystem.writeFileString(helperPath, INSTALL_HELPER, { mode: 0o700 }); + + yield* Effect.scoped( + Effect.gen(function* () { + const helper = yield* childProcessSpawner.spawn( + ChildProcess.make( + "/bin/sh", + [helperPath, String(process.pid), archivePath, targetBundlePath], + { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ), + ); + yield* helper.unref.pipe(Effect.asVoid); + }), + ); + yield* Effect.sync(() => Electron.app.quit()); + }); +}); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 3e3008306..1296bf6d9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -7,6 +7,7 @@ import { getConnectionCatalog, setConnectionCatalog, } from "./methods/connectionCatalog.ts"; +import { setLocalBackendEnabled } from "./methods/localBackend.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -61,6 +62,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); + yield* ipc.handle(setLocalBackendEnabled); 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 5b2c815ea..ec53c347f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -22,6 +22,7 @@ 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 SET_LOCAL_BACKEND_ENABLED_CHANNEL = "desktop:set-local-backend-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/localBackend.ts b/apps/desktop/src/ipc/methods/localBackend.ts new file mode 100644 index 000000000..16dc3b028 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localBackend.ts @@ -0,0 +1,21 @@ +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 * as DesktopIpc from "../DesktopIpc.ts"; + +export const setLocalBackendEnabled = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_LOCAL_BACKEND_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.localBackend.setEnabled")(function* (enabled) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const change = yield* settings.setLocalBackendEnabled(enabled); + if (change.changed) { + yield* lifecycle.relaunch(`localBackendEnabled=${enabled}`); + } + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa..a97240fa8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -126,7 +126,7 @@ const electronLayer = Layer.mergeAll( ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, - ElectronUpdater.layer, + ElectronUpdater.layer.pipe(Layer.provide(NodeServices.layer)), ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 74001dd78..63f24f996 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -50,6 +50,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + setLocalBackendEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_LOCAL_BACKEND_ENABLED_CHANNEL, enabled), getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749a..1957c52ad 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -28,7 +28,7 @@ const DesktopSettingsPatch = Schema.Struct({ serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), + updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly", "canary"])), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), wslBackendEnabled: Schema.optionalKey(Schema.Boolean), wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), @@ -105,6 +105,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + localBackendEnabled: true, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -134,6 +135,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + localBackendEnabled: true, linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, mainWindowMaximized: false, @@ -241,6 +243,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + localBackendEnabled: true, linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, @@ -297,6 +300,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + localBackendEnabled: true, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -345,6 +349,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + localBackendEnabled: true, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -373,6 +378,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + localBackendEnabled: true, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -400,6 +406,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + localBackendEnabled: true, linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index aefc67525..ca5e13700 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 localBackendEnabled: 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 = { + localBackendEnabled: true, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + localBackendEnabled: Schema.optionalKey(Schema.Boolean), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -156,6 +159,9 @@ export class DesktopAppSettings extends Context.Service< bounds: DesktopWindowBounds, isMaximized: boolean, ) => Effect.Effect; + readonly setLocalBackendEnabled: ( + enabled: boolean, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + localBackendEnabled: parsed.localBackendEnabled !== false, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -247,6 +254,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.localBackendEnabled !== defaults.localBackendEnabled) { + document.localBackendEnabled = settings.localBackendEnabled; + } if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -296,6 +306,15 @@ function setServerExposureMode( }; } +function setLocalBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.localBackendEnabled === enabled + ? settings + : { + ...settings, + localBackendEnabled: enabled, + }; +} + function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, @@ -518,6 +537,12 @@ export const make = Effect.gen(function* () { }, }), ), + setLocalBackendEnabled: (enabled) => + persist((settings) => setLocalBackendEnabled(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setLocalBackendEnabled", { + attributes: { enabled }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -577,6 +602,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET load: SynchronizedRef.get(settingsRef), setMainWindowBounds: (bounds, isMaximized) => update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setLocalBackendEnabled: (enabled) => + update((settings) => setLocalBackendEnabled(settings, enabled)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 6bf0e87c9..a6d56f463 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -25,6 +25,7 @@ const clientSettings: ClientSettings = { confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, + contextMenuStyle: "default", confirmThreadUnpin: false, contextWindowMeterEnabled: false, composerCollapseOnScroll: true, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 344d135a1..6de7a3149 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -375,7 +375,7 @@ export const make = Effect.gen(function* () { channel: DesktopUpdateChannel, ) { yield* Effect.annotateCurrentSpan({ channel }); - const allowsPrerelease = channel === "nightly"; + const allowsPrerelease = channel !== "latest"; yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); yield* electronUpdater.setAllowDowngrade(allowsPrerelease); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 731910e44..90deec1ee 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,11 +1,17 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const CANARY_VERSION_PATTERN = /-canary\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { return NIGHTLY_VERSION_PATTERN.test(version); } +export function isCanaryDesktopVersion(version: string): boolean { + return CANARY_VERSION_PATTERN.test(version); +} + export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { + if (isCanaryDesktopVersion(appVersion)) return "canary"; return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; } diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 53a6dc97f..a93b1afa2 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -174,6 +174,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => testSettings), load: Effect.sync(() => testSettings), + setLocalBackendEnabled: () => Effect.die("unexpected local backend toggle"), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index bdd03865c..8e4c2a6a2 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -214,6 +214,7 @@ function makeTestLayer(input: { const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => desktopSettings), load: Effect.sync(() => desktopSettings), + setLocalBackendEnabled: () => Effect.die("unexpected local backend toggle"), setMainWindowBounds: (bounds, isMaximized) => Effect.gen(function* () { if (input.beforeMainWindowBoundsUpdate) { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index d87c74428..300ae3196 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -860,6 +860,11 @@ export const make = Effect.gen(function* () { yield* electronWindow.reveal(existingWindow.value); return; } + const settings = yield* desktopSettings.get; + if (!settings.localBackendEnabled) { + yield* createMain; + return; + } // No real main window yet. While the backend is still cold-booting, // re-reveal the connecting splash so taskbar/dock activation brings it // back instead of doing nothing. Once the backend is ready we fall @@ -891,8 +896,12 @@ export const make = Effect.gen(function* () { dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* focusedMainWindow; - if (Option.isNone(existingWindow) && !(yield* Ref.get(backendReadyRef))) { - return; + if (Option.isNone(existingWindow)) { + const backendReady = yield* Ref.get(backendReadyRef); + const settings = yield* desktopSettings.get; + if (!backendReady && settings.localBackendEnabled) { + return; + } } const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f4ec9135f..bd6ea2ba1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -954,7 +954,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread environmentId={props.environmentId} onClose={dismissUsageLimits} /> -
+ ) : null} {props.selectedThread.goal ? ( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4b2fed86f..1cf0c499c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert } from "react-native"; @@ -15,8 +16,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { - codexFeedbackMessage, parseCodexGoalCommand, parseCodexFeedbackCommand, submitCodexFeedback, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bdbc231fc..aedfd27f9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -56,6 +56,7 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { ProjectLaunchEnv } from "../../projectLaunchEnv/Services/ProjectLaunchEnv.ts"; import { getCodexDefaultModeRequestUserInputConfigValue } from "../../codexModelOptions.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -332,6 +333,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const projectLaunchEnv = yield* Effect.serviceOption(ProjectLaunchEnv); const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -717,6 +719,15 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + const providerProjectLaunchEnv = + project !== undefined && Option.isSome(projectLaunchEnv) + ? yield* projectLaunchEnv.value.resolve({ + projectRoot: project.workspaceRoot, + projectId: project.id, + threadId, + worktreePath: thread.worktreePath, + }) + : undefined; const refreshWorkspaceSnapshot = effectiveCwd ? providerRegistry .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) @@ -733,6 +744,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(providerProjectLaunchEnv ? { env: providerProjectLaunchEnv } : {}), ...(thread.title ? { title: thread.title } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7272d52a6..aec312df1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1068,6 +1068,12 @@ const make = Effect.gen(function* () { .pipe(Effect.map(Option.getOrUndefined)); }); + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + return yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.map(Option.getOrUndefined)); + }); + const getThreadMessageById = Effect.fn("getThreadMessageById")(function* ( threadId: ThreadId, messageId: MessageId, diff --git a/apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvLive.ts b/apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvLive.ts new file mode 100644 index 000000000..57f9549dd --- /dev/null +++ b/apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvLive.ts @@ -0,0 +1,106 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ServerConfig } from "../../config.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectLaunchEnv, type ProjectLaunchEnvShape } from "../Services/ProjectLaunchEnv.ts"; +import { mergeResolvedProjectLaunchEnv } from "../projectLaunchEnvUtils.ts"; +import { + ProjectLaunchEnvProjectLookupError, + ProjectLaunchEnvThreadLookupError, +} from "../Services/ProjectLaunchEnvErrors.ts"; + +export const makeProjectLaunchEnv = Effect.fn("makeProjectLaunchEnv")(function* () { + const serverConfig = yield* ServerConfig; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const resolve: ProjectLaunchEnvShape["resolve"] = (input) => + Effect.succeed( + mergeResolvedProjectLaunchEnv({ + t3Home: serverConfig.baseDir, + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + context: { + projectRoot: input.projectRoot, + projectId: String(input.projectId), + threadId: String(input.threadId), + worktreePath: input.worktreePath ?? undefined, + }, + }), + ); + + const resolveForThread: ProjectLaunchEnvShape["resolveForThread"] = Effect.fn( + "ProjectLaunchEnv.resolveForThread", + )(function* (input) { + const threadOption = yield* projectionSnapshotQuery + .getThreadShellById(ThreadId.make(input.threadId)) + .pipe( + Effect.mapError( + (cause) => + new ProjectLaunchEnvThreadLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + cause, + }), + ), + ); + + const { projectId, worktreePath } = yield* Option.match(threadOption, { + onSome: (thread) => + Effect.succeed({ + projectId: thread.projectId, + worktreePath: input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath, + }), + onNone: () => { + if (input.projectId === undefined) { + return Effect.fail( + new ProjectLaunchEnvThreadLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ); + } + return Effect.succeed({ + projectId: input.projectId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + }, + }); + + const projectOption = yield* projectionSnapshotQuery.getProjectShellById(projectId).pipe( + Effect.mapError( + (cause) => + new ProjectLaunchEnvProjectLookupError({ + projectId: String(projectId), + reason: "statFailed", + cause, + }), + ), + ); + const project = yield* Option.match(projectOption, { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new ProjectLaunchEnvProjectLookupError({ + projectId: String(projectId), + reason: "notFound", + }), + ), + }); + + const env = yield* resolve({ + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + projectRoot: project.workspaceRoot, + projectId: project.id, + threadId: input.threadId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + }); + + return { projectId, worktreePath, env }; + }); + + return { resolve, resolveForThread } satisfies ProjectLaunchEnvShape; +}); + +export const ProjectLaunchEnvLive = Layer.effect(ProjectLaunchEnv, makeProjectLaunchEnv()); diff --git a/apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvTest.ts b/apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvTest.ts new file mode 100644 index 000000000..e3d5d5351 --- /dev/null +++ b/apps/server/src/projectLaunchEnv/Layers/ProjectLaunchEnvTest.ts @@ -0,0 +1,167 @@ +import { + ProjectId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { + ProjectLaunchEnv, + type ProjectLaunchEnvShape, + type ResolvedProjectLaunchEnvForThread, +} from "../Services/ProjectLaunchEnv.ts"; +import { + ProjectLaunchEnvProjectLookupError, + ProjectLaunchEnvThreadLookupError, +} from "../Services/ProjectLaunchEnvErrors.ts"; +import { mergeResolvedProjectLaunchEnv } from "../projectLaunchEnvUtils.ts"; + +export type ProjectLaunchEnvTestFixtures = { + readonly t3Home: string; + readonly projects?: ReadonlyArray; + readonly threads?: ReadonlyArray; +}; + +const toProjectMap = (projects: ReadonlyArray | undefined) => + new Map((projects ?? []).map((project) => [project.id, project] as const)); + +const toThreadMap = (threads: ReadonlyArray | undefined) => + new Map((threads ?? []).map((thread) => [thread.id, thread] as const)); + +export const makeProjectLaunchEnvTestShape = ( + fixtures: ProjectLaunchEnvTestFixtures, +): ProjectLaunchEnvShape => { + const resolve: ProjectLaunchEnvShape["resolve"] = (input) => + Effect.succeed( + mergeResolvedProjectLaunchEnv({ + t3Home: fixtures.t3Home, + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + context: { + projectRoot: input.projectRoot, + projectId: String(input.projectId), + threadId: String(input.threadId), + worktreePath: input.worktreePath ?? undefined, + }, + }), + ); + + const projectsById = toProjectMap(fixtures.projects); + const threadsById = toThreadMap(fixtures.threads); + + const resolveForThread: ProjectLaunchEnvShape["resolveForThread"] = Effect.fn( + "ProjectLaunchEnv.resolveForThread", + )(function* (input) { + const threadOption = yield* Effect.succeed( + Option.fromNullishOr(threadsById.get(ThreadId.make(input.threadId))), + ); + + const { projectId, worktreePath } = yield* Option.match(threadOption, { + onSome: (thread) => + Effect.succeed({ + projectId: thread.projectId, + worktreePath: input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath, + }), + onNone: () => { + if (input.projectId === undefined) { + return Effect.fail( + new ProjectLaunchEnvThreadLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ); + } + + return Effect.succeed({ + projectId: input.projectId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + }, + }); + + const project = yield* Effect.succeed(Option.fromNullishOr(projectsById.get(projectId))).pipe( + Effect.flatMap((projectOption) => + Option.match(projectOption, { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new ProjectLaunchEnvProjectLookupError({ + projectId: String(projectId), + reason: "notFound", + }), + ), + }), + ), + ); + + const env: Record = yield* resolve({ + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + projectRoot: project.workspaceRoot, + projectId: project.id, + threadId: input.threadId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + }); + + return { + projectId, + worktreePath, + env, + } satisfies ResolvedProjectLaunchEnvForThread; + }); + + return { + resolve, + resolveForThread, + }; +}; + +export const projectLaunchEnvTestStub = (fixtures: { + readonly t3Home: string; + readonly projectId: ProjectId; +}): ProjectLaunchEnvShape => { + const resolve: ProjectLaunchEnvShape["resolve"] = (input) => + Effect.succeed( + mergeResolvedProjectLaunchEnv({ + t3Home: fixtures.t3Home, + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + context: { + projectRoot: input.projectRoot, + projectId: String(input.projectId), + threadId: input.threadId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }, + }), + ); + + return { + resolve, + resolveForThread: (resolveInput) => + Effect.succeed({ + projectId: fixtures.projectId, + ...(resolveInput.worktreePath !== undefined + ? { worktreePath: resolveInput.worktreePath } + : {}), + env: Object.fromEntries( + Object.entries(resolveInput.extraEnv ?? {}).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ), + } satisfies ResolvedProjectLaunchEnvForThread), + }; +}; + +export const ProjectLaunchEnvTestLayer = { + stub: (input: { readonly t3Home: string; readonly projectId: ProjectId }) => + Layer.succeed(ProjectLaunchEnv, projectLaunchEnvTestStub(input)), + + withFixtures: (fixtures: ProjectLaunchEnvTestFixtures) => + Layer.succeed(ProjectLaunchEnv, makeProjectLaunchEnvTestShape(fixtures)), +}; + +/** Default CLI/unit-test layer: resolve-only stub with a fixed project id. */ +export const defaultProjectLaunchEnvTestLayer = ProjectLaunchEnvTestLayer.stub({ + t3Home: "/tmp/t3-launch-env-test", + projectId: ProjectId.make("project-1"), +}); diff --git a/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.test.ts b/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.test.ts new file mode 100644 index 000000000..22fc1599a --- /dev/null +++ b/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.test.ts @@ -0,0 +1,144 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_TERMINAL_ID, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { ProjectLaunchEnvTestLayer } from "../Layers/ProjectLaunchEnvTest.ts"; +import { ProjectLaunchEnvThreadLookupError } from "../Services/ProjectLaunchEnvErrors.ts"; +import { ProjectLaunchEnv } from "../Services/ProjectLaunchEnv.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const T3_HOME = "/tmp/t3-launch-env"; +const NOW = "2026-01-01T00:00:00.000Z"; +const DEFAULT_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +} as const; + +const makeProject = (): OrchestrationProjectShell => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, +}); + +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: DEFAULT_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/repo/worktrees/a", + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + goal: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const makeTestLayer = (threads: ReadonlyArray) => + ProjectLaunchEnvTestLayer.withFixtures({ + t3Home: T3_HOME, + projects: [makeProject()], + threads, + }); + +describe("ProjectLaunchEnv.resolveForThread", () => { + it.effect("resolves project launch env using the thread project id", () => + Effect.gen(function* () { + const projectLaunchEnv = yield* ProjectLaunchEnv; + const result = yield* projectLaunchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + }); + + assert.deepStrictEqual(result.env, { + T3CODE_HOME: T3_HOME, + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }); + assert.strictEqual(result.worktreePath, "/repo/worktrees/a"); + }).pipe(Effect.provide(makeTestLayer([makeThread()]))), + ); + + it.effect("ignores client projectId when the thread already exists", () => + Effect.gen(function* () { + const projectLaunchEnv = yield* ProjectLaunchEnv; + const spoofedProjectId = ProjectId.make("project-spoofed"); + const result = yield* projectLaunchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: spoofedProjectId, + }); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + assert.strictEqual(result.projectId, PROJECT_ID); + }).pipe(Effect.provide(makeTestLayer([makeThread()]))), + ); + + it.effect("resolves project launch env for draft threads using client projectId", () => + Effect.gen(function* () { + const projectLaunchEnv = yield* ProjectLaunchEnv; + const result = yield* projectLaunchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, + }); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + assert.strictEqual(result.env.T3CODE_THREAD_ID, "thread-1"); + }).pipe(Effect.provide(makeTestLayer([]))), + ); + + it.effect("fails when the thread is not found and projectId is omitted", () => + Effect.gen(function* () { + const projectLaunchEnv = yield* ProjectLaunchEnv; + const error = yield* Effect.flip( + projectLaunchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + }), + ); + + assert.instanceOf(error, ProjectLaunchEnvThreadLookupError); + }).pipe(Effect.provide(makeTestLayer([]))), + ); + + it.effect("prefers explicit worktreePath over the thread default", () => + Effect.gen(function* () { + const projectLaunchEnv = yield* ProjectLaunchEnv; + const result = yield* projectLaunchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + worktreePath: "/repo/worktrees/b", + }); + + assert.strictEqual(result.worktreePath, "/repo/worktrees/b"); + assert.strictEqual(result.env.T3CODE_WORKTREE_PATH, "/repo/worktrees/b"); + }).pipe(Effect.provide(makeTestLayer([makeThread()]))), + ); +}); diff --git a/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.ts b/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.ts new file mode 100644 index 000000000..74d3e6316 --- /dev/null +++ b/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnv.ts @@ -0,0 +1,45 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { EnvRecord } from "../projectLaunchEnvUtils.ts"; +import { + ProjectLaunchEnvProjectLookupError, + ProjectLaunchEnvThreadLookupError, +} from "./ProjectLaunchEnvErrors.ts"; + +export interface ResolveProjectLaunchEnvInput { + readonly projectRoot: string; + readonly projectId: ProjectId | string; + readonly threadId: ThreadId; + readonly worktreePath?: string | null | undefined; + readonly extraEnv?: EnvRecord; +} + +export interface ResolveProjectLaunchEnvForThreadInput { + readonly threadId: ThreadId; + readonly terminalId?: string | undefined; + readonly projectId?: ProjectId | undefined; + readonly worktreePath?: string | null | undefined; + readonly extraEnv?: EnvRecord; +} + +export type ResolvedProjectLaunchEnvForThread = { + readonly projectId: ProjectId; + readonly worktreePath?: string | null | undefined; + readonly env: Record; +}; + +export interface ProjectLaunchEnvShape { + readonly resolve: (input: ResolveProjectLaunchEnvInput) => Effect.Effect>; + readonly resolveForThread: ( + input: ResolveProjectLaunchEnvForThreadInput, + ) => Effect.Effect< + ResolvedProjectLaunchEnvForThread, + ProjectLaunchEnvProjectLookupError | ProjectLaunchEnvThreadLookupError + >; +} + +export class ProjectLaunchEnv extends Context.Service()( + "t3/projectLaunchEnv/Services/ProjectLaunchEnv", +) {} diff --git a/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnvErrors.ts b/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnvErrors.ts new file mode 100644 index 000000000..4d863b957 --- /dev/null +++ b/apps/server/src/projectLaunchEnv/Services/ProjectLaunchEnvErrors.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; + +export class ProjectLaunchEnvProjectLookupError extends Schema.TaggedErrorClass()( + "ProjectLaunchEnvProjectLookupError", + { + projectId: Schema.String, + reason: Schema.Enum({ notFound: "notFound", statFailed: "statFailed" }), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "notFound" + ? `Project not found: ${this.projectId}` + : `Failed to stat project: ${this.projectId}`; + } +} + +export class ProjectLaunchEnvThreadLookupError extends Schema.TaggedErrorClass()( + "ProjectLaunchEnvThreadLookupError", + { + threadId: Schema.String, + terminalId: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Thread not found: ${this.threadId}`; + } +} diff --git a/apps/server/src/projectLaunchEnv/projectLaunchEnv.test.ts b/apps/server/src/projectLaunchEnv/projectLaunchEnv.test.ts new file mode 100644 index 000000000..e0d53b7ea --- /dev/null +++ b/apps/server/src/projectLaunchEnv/projectLaunchEnv.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildLaunchContextEnv, mergeResolvedProjectLaunchEnv } from "./projectLaunchEnvUtils.ts"; + +describe("projectLaunchEnvUtils", () => { + it("builds launch context env", () => { + expect( + buildLaunchContextEnv({ + projectRoot: "/repo", + projectId: "project-1", + threadId: "thread-1", + worktreePath: "/repo/worktree-a", + }), + ).toEqual({ + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + }); + }); + + it("merges custom env with authoritative server and launch values", () => { + expect( + mergeResolvedProjectLaunchEnv({ + extraEnv: { + T3CODE_PROJECT_ROOT: "/custom-root", + T3CODE_PORT: "3773", + CUSTOM_FLAG: "1", + }, + t3Home: "/data/.t3", + context: { + projectRoot: "/repo", + projectId: "project-1", + threadId: "thread-1", + worktreePath: "/repo/worktree-a", + }, + }), + ).toEqual({ + CUSTOM_FLAG: "1", + T3CODE_HOME: "/data/.t3", + T3CODE_PORT: "3773", + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + }); + }); +}); diff --git a/apps/server/src/projectLaunchEnv/projectLaunchEnvUtils.ts b/apps/server/src/projectLaunchEnv/projectLaunchEnvUtils.ts new file mode 100644 index 000000000..b6702045d --- /dev/null +++ b/apps/server/src/projectLaunchEnv/projectLaunchEnvUtils.ts @@ -0,0 +1,37 @@ +import { + type EnvRecord, + isManagedRuntimeEnvKey, + stripManagedRuntimeEnvKeys, +} from "@t3tools/shared/projectLaunchEnv"; + +export type { EnvRecord }; +export { isManagedRuntimeEnvKey, stripManagedRuntimeEnvKeys }; + +export interface ProjectLaunchEnvContextInput { + readonly projectRoot: string; + readonly projectId: string; + readonly threadId: string; + readonly worktreePath?: string | null | undefined; +} + +export function buildLaunchContextEnv(input: ProjectLaunchEnvContextInput): Record { + const env: Record = { + T3CODE_PROJECT_ROOT: input.projectRoot, + T3CODE_PROJECT_ID: input.projectId, + T3CODE_THREAD_ID: input.threadId, + }; + if (input.worktreePath) env.T3CODE_WORKTREE_PATH = input.worktreePath; + return env; +} + +export function mergeResolvedProjectLaunchEnv(input: { + readonly t3Home: string; + readonly extraEnv?: EnvRecord; + readonly context: ProjectLaunchEnvContextInput; +}): Record { + return { + ...stripManagedRuntimeEnvKeys(input.extraEnv), + T3CODE_HOME: input.t3Home, + ...buildLaunchContextEnv(input.context), + }; +} diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 65a8c97fe..14f96275d 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -48,7 +48,10 @@ import { type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + mergeProviderInstanceEnvironment, + mergeProviderSessionEnvironment, +} from "../ProviderInstanceEnvironment.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; @@ -119,7 +122,9 @@ export const AntigravityDriver: ProviderDriver, + input: Omit & { + readonly environment?: NodeJS.ProcessEnv; + }, ): Effect.fn.Return< AcpSessionRuntime["Service"], AcpError | ProviderSetupError, @@ -132,8 +137,12 @@ export const AntigravityDriver: ProviderDriver @@ -146,7 +155,7 @@ export const AntigravityDriver: ProviderDriver, + input: Omit< + AntigravityAcpRuntimeInput, + "spawn" | "childProcessSpawner" | "onAuthorizationUrl" + > & { + readonly environment?: NodeJS.ProcessEnv; + }, ) => Effect.Effect; readonly withProcess: AntigravityAuth["withProcess"]; readonly onSessionStarted?: ( @@ -791,6 +796,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi // leaf directory holding only uploads. const runtime = yield* options.makeRuntime({ cwd, + ...(input.env ? { environment: input.env } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, clientFileSystem: true, additionalDirectories: [serverConfig.attachmentsDir], diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e46024fa4..cb2a5f202 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -84,6 +84,7 @@ import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; @@ -4654,6 +4655,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const sessionEnvironment = mergeProviderSessionEnvironment(claudeEnvironment, input.env); // The attachments dir grant lets the agent Read/copy pasted images at // the paths ProviderService injects into the turn text, without an // approval prompt. It is a leaf directory holding only attachment @@ -4691,7 +4693,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], - env: claudeEnvironment, + env: sessionEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 3c69291ad..9506fa235 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -76,6 +76,8 @@ import { } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { stripManagedRuntimeEnvKeys } from "@t3tools/shared/projectLaunchEnv"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { codexRateLimitsToUpdate } from "./codexUsageLimits.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); @@ -2263,6 +2265,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") === "1m" && supportsCodexLongContext(input.modelSelection.model); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const sessionEnvironment = + options?.environment === undefined && input.env === undefined + ? undefined + : mergeProviderSessionEnvironment(options?.environment, input.env); const appServerArgs = [ ...(useLongContext ? ["-c", "model_context_window=1000000", "-c", "model_auto_compact_token_limit=900000"] @@ -2281,8 +2287,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, - launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), + launchArgs: resolveCodexLaunchArgs( + codexConfig.launchArgs, + sessionEnvironment ?? process.env, + ), + ...(sessionEnvironment ? { environment: sessionEnvironment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } @@ -2296,7 +2305,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(mcpSession ? { environment: { - ...(options?.environment ?? process.env), + ...(sessionEnvironment ?? stripManagedRuntimeEnvKeys(process.env)), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, } @@ -2541,14 +2550,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); - const compactThread: NonNullable = Effect.fn("compactThread")( - function* (threadId) { - const session = yield* requireSession(threadId); - yield* session.runtime.compactThread.pipe( - Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), - ); - }, - ); + const compactThread = Effect.fn("compactThread")(function* (threadId: ThreadId) { + const session = yield* requireSession(threadId); + yield* session.runtime.compactThread.pipe( + Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), + ); + }); const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 1a77f964a..637373125 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -79,6 +79,7 @@ import { import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { discoverCursorSkills, hasCursorSkillMention, @@ -543,7 +544,7 @@ export function makeCursorAdapter( const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), + environment: mergeProviderSessionEnvironment(options?.environment, input.env), childProcessSpawner, cwd, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 25188adcf..46d568094 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -81,6 +81,7 @@ import { } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -987,9 +988,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const sessionEnvironment = mergeProviderSessionEnvironment( + options?.environment, + input.env, + ); const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment ? { environment: options.environment } : {}), + environment: sessionEnvironment, childProcessSpawner, cwd, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 742ee9b86..4b2443270 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -36,6 +36,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -2821,6 +2822,10 @@ export function makeOpenCodeAdapter( } const started = yield* Effect.gen(function* () { + const sessionEnvironment = mergeProviderSessionEnvironment( + options?.environment, + input.env, + ); const sessionScope = yield* Scope.make(); const startedExit = yield* Effect.exit( Effect.gen(function* () { @@ -2832,7 +2837,7 @@ export function makeOpenCodeAdapter( directory, serverUrl, ...(serverPassword ? { serverPassword } : {}), - ...(options?.environment ? { environment: options.environment } : {}), + environment: sessionEnvironment, }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7d3107af8..f029f285a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -75,6 +75,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectLaunchEnv } from "../../projectLaunchEnv/Services/ProjectLaunchEnv.ts"; const isModelSelection = Schema.is(ModelSelection); /** How long a manual context compaction may run before ProviderService gives up on it. */ @@ -332,6 +333,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const projectionQuery = yield* Effect.serviceOption( ProjectionSnapshotQuery.ProjectionSnapshotQuery, ); + const projectLaunchEnv = yield* Effect.serviceOption(ProjectLaunchEnv); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = @@ -1045,6 +1047,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + const resolvedProjectLaunchEnv = Option.isSome(projectLaunchEnv) + ? yield* projectLaunchEnv.value + .resolveForThread({ threadId: input.binding.threadId }) + .pipe( + Effect.mapError((cause) => + toValidationError( + input.operation, + `Cannot resolve launch environment for thread '${input.binding.threadId}': ${cause.message}`, + cause, + ), + ), + ) + : undefined; yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); const resumed = yield* adapter @@ -1055,6 +1070,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + ...(resolvedProjectLaunchEnv ? { env: resolvedProjectLaunchEnv.env } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }) .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index 77c0c6c2d..975ffa988 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,4 +1,9 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { + isManagedRuntimeEnvKey, + stripManagedRuntimeEnvKeys, + type EnvRecord, +} from "../projectLaunchEnv/projectLaunchEnvUtils.ts"; import { expandHomePath } from "../pathExpansion.ts"; @@ -7,12 +12,13 @@ export function mergeProviderInstanceEnvironment( baseEnv: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { if (!environment || environment.length === 0) { - return baseEnv; + return stripManagedRuntimeEnvKeys(baseEnv); } - const next: NodeJS.ProcessEnv = { ...baseEnv }; + const next = stripManagedRuntimeEnvKeys(baseEnv); for (const variable of environment) { // Child processes do not apply shell expansion to environment values. + if (isManagedRuntimeEnvKey(variable.name)) continue; next[variable.name] = variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" ? expandHomePath(variable.value) @@ -20,3 +26,15 @@ export function mergeProviderInstanceEnvironment( } return next; } + +export function mergeProviderSessionEnvironment( + baseEnv: EnvRecord | undefined, + sessionEnv: EnvRecord | undefined, +): Record { + const next = stripManagedRuntimeEnvKeys(baseEnv ?? process.env); + if (!sessionEnv) return next; + for (const [key, value] of Object.entries(sessionEnv)) { + if (value !== undefined) next[key] = value; + } + return next; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 349644966..a8658edee 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -54,6 +54,7 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import { ProjectLaunchEnvLive } from "./projectLaunchEnv/Layers/ProjectLaunchEnvLive.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -126,7 +127,10 @@ import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinar import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { + OrchestrationInfrastructureLayerLive, + OrchestrationLayerLive, +} from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -371,9 +375,15 @@ const CheckpointingLayerLive = Layer.empty.pipe( const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); +const ProjectLaunchEnvLayerLive = ProjectLaunchEnvLive.pipe( + Layer.provideMerge(OrchestrationInfrastructureLayerLive), + Layer.provideMerge(PersistenceLayerLive), +); + const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), + Layer.provide(ProjectLaunchEnvLayerLive), Layer.provide(NativeTelemetryLayerLive), ); @@ -463,6 +473,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), ), Layer.provideMerge(GitLayerLive), + Layer.provideMerge(ProjectLaunchEnvLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 10143b7be..5bb438a72 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -8,6 +8,8 @@ */ import { DEFAULT_TERMINAL_ID, + ProjectId, + ThreadId, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, @@ -73,6 +75,11 @@ import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; +import { ProjectLaunchEnv } from "../projectLaunchEnv/Services/ProjectLaunchEnv.ts"; +import { + ProjectLaunchEnvProjectLookupError, + ProjectLaunchEnvThreadLookupError, +} from "../projectLaunchEnv/Services/ProjectLaunchEnvErrors.ts"; export { TerminalCwdError, @@ -1342,6 +1349,7 @@ interface TerminalManagerOptions { Record, TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError >; + projectLaunchEnv?: ProjectLaunchEnv["Service"]; } export const resolveProviderInstanceTerminalEnvironment = Effect.fn( @@ -1402,6 +1410,7 @@ export const make = Effect.fn("TerminalManager.make")(function* () { env, }), ); + const projectLaunchEnv = yield* Effect.serviceOption(ProjectLaunchEnv); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, @@ -1413,6 +1422,7 @@ export const make = Effect.fn("TerminalManager.make")(function* () { registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, resolveProviderInstanceEnvironment, + ...(Option.isSome(projectLaunchEnv) ? { projectLaunchEnv: projectLaunchEnv.value } : {}), }); }); @@ -1423,6 +1433,68 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); + const definedEnv = ( + env: Readonly> | undefined, + ): Record => + Object.fromEntries( + Object.entries(env ?? {}).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + const projectLaunchEnv = + options.projectLaunchEnv ?? + ProjectLaunchEnv.of({ + resolve: (input) => Effect.succeed(definedEnv(input.extraEnv)), + resolveForThread: (input) => + Effect.succeed({ + projectId: input.projectId ?? ProjectId.make("test-project"), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + env: definedEnv(input.extraEnv), + }), + }); + + const toProjectLaunchEnvInput = ( + input: Pick< + TerminalOpenInput | TerminalRestartInput | TerminalAttachInput, + "threadId" | "terminalId" | "projectId" | "worktreePath" | "env" + >, + ) => ({ + threadId: ThreadId.make(input.threadId), + terminalId: input.terminalId, + ...(input.projectId !== undefined ? { projectId: ProjectId.make(input.projectId) } : {}), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + const mapProjectLaunchEnvError = ( + error: ProjectLaunchEnvProjectLookupError | ProjectLaunchEnvThreadLookupError, + ) => { + if (error._tag === "ProjectLaunchEnvThreadLookupError") { + return new TerminalSessionLookupError({ + threadId: error.threadId, + terminalId: error.terminalId ?? "", + }); + } + if (error.reason === "notFound") { + return new TerminalCwdNotFoundError({ cwd: error.projectId }); + } + return new TerminalCwdStatError({ cwd: error.projectId, cause: error.cause ?? error }); + }; + + const resolveProjectLaunchEnv = < + Input extends TerminalOpenInput | TerminalAttachInput | TerminalRestartInput, + >( + input: Input, + ) => + projectLaunchEnv.resolveForThread(toProjectLaunchEnvInput(input)).pipe( + Effect.mapError(mapProjectLaunchEnvError), + Effect.map((resolved) => ({ + ...input, + projectId: resolved.projectId, + ...(resolved.worktreePath !== undefined ? { worktreePath: resolved.worktreePath } : {}), + env: resolved.env, + })), + ); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; @@ -1438,19 +1510,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( function* ( input: Input, - ): Effect.fn.Return< - Input, - TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError - > { - if (input.providerInstanceId === undefined) return input; - const resolver = options.resolveProviderInstanceEnvironment; - if (resolver === undefined) { - return yield* new TerminalProviderInstanceNotFoundError({ - providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), - }); + ) { + let resolvedInput = input; + if (input.providerInstanceId !== undefined) { + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + resolvedInput = { ...input, env }; } - const env = yield* resolver(input.providerInstanceId, input.env); - return { ...input, env }; + return resolvedInput.projectId === undefined + ? resolvedInput + : yield* resolveProjectLaunchEnv(resolvedInput); }, ); // One process-table snapshot per poll tick, shared across every terminal. diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e..b405a901e 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -1,4 +1,5 @@ const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const CANARY_SERVER_VERSION_PATTERN = /-canary\.\d{8}\.\d+$/; export function formatAppDisplayName(input: { readonly baseName: string; @@ -15,10 +16,10 @@ export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; }): string { - return input.primaryServerVersion && - NIGHTLY_SERVER_VERSION_PATTERN.test(input.primaryServerVersion) - ? "Nightly" - : input.fallbackStageLabel; + const version = input.primaryServerVersion ?? ""; + if (CANARY_SERVER_VERSION_PATTERN.test(version)) return "Canary"; + if (NIGHTLY_SERVER_VERSION_PATTERN.test(version)) return "Nightly"; + return input.fallbackStageLabel; } export function resolveServerBackedAppDisplayName(input: { diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 7fc57cf0d..9ad315115 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -13,9 +13,17 @@ const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); export const HOSTED_APP_CHANNEL = - hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; + hostedAppChannel === "latest" || hostedAppChannel === "nightly" || hostedAppChannel === "canary" + ? hostedAppChannel + : null; export const HOSTED_APP_CHANNEL_LABEL = - HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; + HOSTED_APP_CHANNEL === "canary" + ? "Canary" + : HOSTED_APP_CHANNEL === "nightly" + ? "Nightly" + : HOSTED_APP_CHANNEL === "latest" + ? "Latest" + : null; export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a3b6d2aa4..faf2f06ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -986,7 +986,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const splitTerminal = useCallback(() => { - if (!cwd) { + if (!cwd || !project) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); @@ -997,6 +997,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra input: { threadId, terminalId, + projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, @@ -1007,6 +1008,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, + project, runtimeEnv, storeSplitTerminal, threadId, @@ -1014,7 +1016,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra openTerminal, ]); const splitTerminalVertical = useCallback(() => { - if (!cwd) { + if (!cwd || !project) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); @@ -1025,6 +1027,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra input: { threadId, terminalId, + projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, @@ -1036,6 +1039,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra cwd, effectiveWorktreePath, openTerminal, + project, runtimeEnv, storeSplitTerminalVertical, threadId, @@ -1043,7 +1047,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ]); const createNewTerminal = useCallback(() => { - if (!cwd) { + if (!cwd || !project) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); @@ -1054,6 +1058,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra input: { threadId, terminalId, + projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, @@ -1069,6 +1074,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, openTerminal, + project, ]); const activateTerminal = useCallback( @@ -1142,6 +1148,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra void; +}) { + if (props.environments.length <= 1) return null; + const visibleEnvironmentCount = props.environments.filter( + (environment) => environment.visible, + ).length; + const hiddenCount = props.environments.length - visibleEnvironmentCount; + return ( + + + + } + /> + } + > + + + + {hiddenCount > 0 + ? `${hiddenCount} hidden environment${hiddenCount === 1 ? "" : "s"}` + : "Sidebar environments"} + + + + + {props.environments.map((environment) => { + const isLastVisibleEnvironment = environment.visible && visibleEnvironmentCount === 1; + return ( + { + if (isLastVisibleEnvironment && checked !== true) return; + props.onVisibilityChange(environment.environmentId, checked === true); + }} + > + + {environment.label} + + {environment.projectCount} project{environment.projectCount === 1 ? "" : "s"} + + + + ); + })} + + + + ); +} + function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; return label.endsWith(" ago") ? label.slice(0, -4) : label; @@ -805,6 +880,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectFaviconPathByKey: ReadonlyMap; projectIconByKey: ReadonlyMap; scopedProjectKeys: ReadonlySet | null; + hiddenEnvironmentIds: ReadonlySet; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; }) { @@ -844,6 +920,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { if (session.promotedTo != null) { continue; } + if (props.hiddenEnvironmentIds.has(session.environmentId)) continue; if ( props.scopedProjectKeys !== null && !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) @@ -873,6 +950,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { frozenActive, props.routeDraftId, props.scopedProjectKeys, + props.hiddenEnvironmentIds, ]); const handleDiscard = useCallback( (draftId: DraftId) => { @@ -1539,6 +1617,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • store.projectOrder); - const threads = useThreadShells(); + const allThreads = useThreadShells(); + const sidebarEnvironmentHiddenById = useUiStateStore( + (store) => store.sidebarEnvironmentHiddenById, + ); + const setSidebarEnvironmentVisible = useUiStateStore( + (store) => store.setSidebarEnvironmentVisible, + ); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -2165,6 +2251,47 @@ export default function Sidebar() { ); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentVisibilityOptions = useMemo(() => { + const projectCounts = new Map(); + for (const project of allProjects) { + projectCounts.set(project.environmentId, (projectCounts.get(project.environmentId) ?? 0) + 1); + } + return environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + visible: + environments.length <= 1 || + sidebarEnvironmentHiddenById[environment.environmentId] !== true, + projectCount: projectCounts.get(environment.environmentId) ?? 0, + })); + }, [allProjects, environments, sidebarEnvironmentHiddenById]); + const hiddenEnvironmentIds = useMemo( + () => + new Set( + environmentVisibilityOptions + .filter((environment) => !environment.visible) + .map((environment) => environment.environmentId), + ), + [environmentVisibilityOptions], + ); + const visibleEnvironmentKey = environmentVisibilityOptions + .filter((environment) => environment.visible) + .map((environment) => environment.environmentId) + .join("\0"); + const projects = useMemo( + () => + environments.length <= 1 + ? allProjects + : allProjects.filter((project) => !hiddenEnvironmentIds.has(project.environmentId)), + [allProjects, environments.length, hiddenEnvironmentIds], + ); + const threads = useMemo( + () => + environments.length <= 1 + ? allThreads + : allThreads.filter((thread) => !hiddenEnvironmentIds.has(thread.environmentId)), + [allThreads, environments.length, hiddenEnvironmentIds], + ); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -2177,6 +2304,9 @@ export default function Sidebar() { }, [markThreadVisited], ); + useEffect(() => { + clearSelection(); + }, [clearSelection, visibleEnvironmentKey]); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -2189,6 +2319,10 @@ export default function Sidebar() { [routeDraftThread, routeTarget], ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const markSidebarThreadNavigation = useSidebarActiveThreadScroll({ + hasThreadRoute: routeTarget !== null, + routeThreadKey, + }); const routeTargetRef = useRef(routeTarget); routeTargetRef.current = routeTarget; // Post-settle navigation validates against the CURRENT route, not the one @@ -2794,6 +2928,13 @@ export default function Sidebar() { }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const navigateToThreadFromSidebar = useCallback( + (threadRef: ScopedThreadRef) => { + markSidebarThreadNavigation(scopedThreadKey(threadRef)); + navigateToThread(threadRef); + }, + [markSidebarThreadNavigation, navigateToThread], + ); const navigateToDraft = useCallback( (draftId: DraftId) => { @@ -4339,6 +4480,10 @@ export default function Sidebar() { + {projectGroups.length > 0 ? (
    @@ -4716,7 +4861,7 @@ export default function Sidebar() { } timestampFormat={timestampFormat} onThreadClick={handleThreadClick} - onThreadActivate={navigateToThread} + onThreadActivate={navigateToThreadFromSidebar} onStartRename={startThreadRename} onRenameTitleChange={setRenamingTitle} onCommitRename={commitThreadRename} @@ -4762,6 +4907,7 @@ export default function Sidebar() { projectFaviconPathByKey={projectFaviconPathByKey} projectIconByKey={projectIconByKey} scopedProjectKeys={scopedProjectKeys} + hiddenEnvironmentIds={hiddenEnvironmentIds} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} />, diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 9f77d7298..799469273 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -6,7 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic"; import { primaryServerConfigAtom } from "../state/server"; export type SidebarStageBackdropVariant = "nightly" | "dev"; -export type EnvironmentIdentificationPillLabel = "Dev" | "Nightly"; +export type EnvironmentIdentificationPillLabel = "Canary" | "Dev" | "Nightly"; // A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals // more horizontal canvas instead of zooming the scene. @@ -18,6 +18,7 @@ export function resolveSidebarStageBackdropVariant( ): SidebarStageBackdropVariant | null { if (!enabled) return null; const normalized = stageLabel.trim().toLowerCase(); + if (normalized === "canary") return "dev"; if (normalized === "nightly") return "nightly"; if (normalized === "dev") return "dev"; return null; @@ -35,6 +36,7 @@ export function resolveEnvironmentIdentificationPillLabel( stageLabel: string, ): EnvironmentIdentificationPillLabel | null { const normalized = stageLabel.trim().toLowerCase(); + if (normalized === "canary") return "Canary"; if (normalized === "dev") return "Dev"; if (normalized === "nightly") return "Nightly"; return null; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225..d2c6f2ce7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -21,6 +21,7 @@ import { import { type ContextMenuItem, type ProviderInstanceId, + type ProjectId, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -310,6 +311,7 @@ interface TerminalViewportProps { advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; + projectId?: ProjectId; terminalId: string; terminalLabel: string; cwd: string; @@ -336,6 +338,7 @@ export function TerminalViewport({ advancedTypography, threadRef, threadId, + projectId, terminalId, terminalLabel, cwd, @@ -406,6 +409,7 @@ export function TerminalViewport({ terminal: { threadId, terminalId, + ...(projectId !== undefined ? { projectId } : {}), cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), @@ -920,7 +924,9 @@ export function TerminalViewport({ teardown?.(); if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; - }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); + // autoFocus is intentionally omitted; + // it is only read at mount time and must not trigger terminal teardown/recreation. + }, [cwd, environmentId, projectId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { const terminal = terminalRef.current; @@ -990,6 +996,7 @@ interface ThreadTerminalDrawerProps { mode?: "drawer" | "panel"; threadRef: ScopedThreadRef; threadId: ThreadId; + projectId: ProjectId; cwd: string; worktreePath?: string | null; runtimeEnv?: Record; @@ -1051,6 +1058,7 @@ export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, threadId, + projectId, cwd, worktreePath, runtimeEnv, @@ -1531,6 +1539,7 @@ export default function ThreadTerminalDrawer({ advancedTypography={advancedTypography} threadRef={threadRef} threadId={threadId} + projectId={projectId} terminalId={terminalId} terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} cwd={terminalLaunchLocation.cwd} @@ -1561,6 +1570,7 @@ export default function ThreadTerminalDrawer({ key={resolvedActiveTerminalId} threadRef={threadRef} threadId={threadId} + projectId={projectId} terminalId={resolvedActiveTerminalId} terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} cwd={activeTerminalLaunchLocation.cwd} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ad7665651..b808200dc 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -106,7 +106,7 @@ import { AnimatedHeight } from "../AnimatedHeight"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; -import { readHostedPairingRequest } from "../../hostedPairing"; +import { isDesktopBackendless, readHostedPairingRequest } from "../../hostedPairing"; import { createServerPairingCredential, revokeOtherServerClientSessions, @@ -1773,6 +1773,7 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const desktopBackendless = isDesktopBackendless(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); @@ -1817,6 +1818,11 @@ export function ConnectionsSettings() { const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< string | null >(null); + const [localBackendMutationError, setLocalBackendMutationError] = useState(null); + const [pendingLocalBackendEnabled, setPendingLocalBackendEnabled] = useState( + null, + ); + const [isUpdatingLocalBackend, setIsUpdatingLocalBackend] = useState(false); const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState< string | null >(null); @@ -1907,7 +1913,9 @@ export function ConnectionsSettings() { : null, ); const desktopNetworkAccess = useEnvironmentQuery( - canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, + canManageLocalBackend && desktopBridge && !desktopBackendless + ? desktopNetworkAccessStateAtom + : null, ); const isSshDiscoveryActive = desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh"; @@ -1921,7 +1929,7 @@ export function ConnectionsSettings() { if (isSshDiscoveryActive) refreshDesktopSshHosts(); }, [isSshDiscoveryActive, refreshDesktopSshHosts]); const desktopWsl = useEnvironmentQuery( - canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null, + canManageLocalBackend && desktopBridge && !desktopBackendless ? desktopWslStateAtom : null, ); const desktopWslState = desktopWsl.data; const desktopWslError = desktopWslMutationError ?? desktopWsl.error; @@ -2000,6 +2008,29 @@ export function ConnectionsSettings() { } }, [isTailscaleServePortValid, parsedTailscaleServePort, pendingTailscaleServeEndpoint]); + const handleConfirmLocalBackendChange = useCallback(async () => { + if (!desktopBridge || pendingLocalBackendEnabled === null) return; + const enabled = pendingLocalBackendEnabled; + setIsUpdatingLocalBackend(true); + setLocalBackendMutationError(null); + try { + await desktopBridge.setLocalBackendEnabled(enabled); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update the local backend."; + setPendingLocalBackendEnabled(null); + setLocalBackendMutationError(message); + setIsUpdatingLocalBackend(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update local backend", + description: message, + }), + ); + } + }, [desktopBridge, pendingLocalBackendEnabled]); + const handleDesktopServerExposureChange = useCallback( async (checked: boolean) => { if (!desktopBridge) return; @@ -3131,7 +3162,74 @@ export function ConnectionsSettings() { return ( - {canManageLocalBackend ? ( + {desktopBridge ? ( + + {localBackendMutationError} + ) : null + } + control={ + + } + /> + + ) : null} + + { + if (isUpdatingLocalBackend) return; + if (!open) setPendingLocalBackendEnabled(null); + }} + > + + + + {pendingLocalBackendEnabled ? "Enable local backend?" : "Disable local backend?"} + + + {pendingLocalBackendEnabled + ? "T3 Code will restart and start the backend on this computer. Your local projects and threads will become available again." + : "T3 Code will restart without a backend on this computer. Local projects and threads stay on disk and return when you enable it again."} + + + + } + > + Cancel + + + + + + + {canManageLocalBackend && !desktopBackendless ? ( <> {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( @@ -3508,7 +3606,7 @@ export function ConnectionsSettings() { - ) : ( + ) : desktopBridge ? null : ( ; + +const COMPOSER_COLLAPSE_TRIGGER_LABELS = { + blur: "On unfocus", + scroll: "On scroll", +} as const; +type ComposerCollapseTrigger = keyof typeof COMPOSER_COLLAPSE_TRIGGER_LABELS; const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -416,7 +428,7 @@ function AboutVersionSection() { {hasDesktopBridge ? ( - {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} + {selectedUpdateChannel === "canary" + ? "Canary" + : selectedUpdateChannel === "nightly" + ? "Nightly" + : "Stable"} @@ -441,6 +457,9 @@ function AboutVersionSection() { Nightly + + Canary + } @@ -469,6 +488,9 @@ function AboutVersionSection() { Nightly + + Canary + } @@ -516,6 +538,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.contextMenuStyle !== DEFAULT_UNIFIED_SETTINGS.contextMenuStyle + ? ["Context menu style"] + : []), ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), @@ -630,6 +655,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, settings.timestampFormat, + settings.contextMenuStyle, settings.wordWrap, followSystem, theme, @@ -702,6 +728,7 @@ export function useSettingsRestore(onRestored?: () => void) { updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + contextMenuStyle: DEFAULT_UNIFIED_SETTINGS.contextMenuStyle, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, @@ -2231,6 +2258,45 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ contextMenuStyle: DEFAULT_UNIFIED_SETTINGS.contextMenuStyle }) + } + /> + ) : null + } + control={ + + } + /> 0; + return ( + showUpdateDetails && + state !== null && + state.channel !== "latest" && + state.releaseNotes.length > 0 + ); } export function handleSidebarUpdateReleaseNotesPopoverOpenChange( diff --git a/apps/web/src/components/sidebar/useSidebarActiveThreadScroll.ts b/apps/web/src/components/sidebar/useSidebarActiveThreadScroll.ts new file mode 100644 index 000000000..7c4fa68fa --- /dev/null +++ b/apps/web/src/components/sidebar/useSidebarActiveThreadScroll.ts @@ -0,0 +1,76 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; + +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useSidebarVisibility } from "../ui/sidebar"; + +export function useSidebarActiveThreadScroll(input: { + readonly hasThreadRoute: boolean; + readonly routeThreadKey: string | null; +}) { + const { hasThreadRoute, routeThreadKey } = input; + const sidebarIsVisible = useSidebarVisibility(); + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const sidebarWasVisibleRef = useRef(false); + const lastRouteThreadKeyRef = useRef(routeThreadKey); + const initialScrollPendingRef = useRef(true); + const sidebarNavigationThreadKeyRef = useRef(null); + + const markSidebarThreadNavigation = useCallback((threadKey: string) => { + sidebarNavigationThreadKeyRef.current = threadKey; + }, []); + + useLayoutEffect(() => { + const sidebarBecameVisible = sidebarIsVisible && !sidebarWasVisibleRef.current; + const routeThreadChanged = + routeThreadKey !== null && lastRouteThreadKeyRef.current !== routeThreadKey; + const routeChangedFromSidebar = + routeThreadChanged && sidebarNavigationThreadKeyRef.current === routeThreadKey; + + if (!sidebarIsVisible) { + sidebarWasVisibleRef.current = false; + sidebarNavigationThreadKeyRef.current = null; + return; + } + if (!routeThreadKey) { + sidebarWasVisibleRef.current = true; + if (!hasThreadRoute) { + initialScrollPendingRef.current = true; + lastRouteThreadKeyRef.current = null; + } + return; + } + if (routeChangedFromSidebar) { + sidebarWasVisibleRef.current = true; + lastRouteThreadKeyRef.current = routeThreadKey; + initialScrollPendingRef.current = false; + sidebarNavigationThreadKeyRef.current = null; + return; + } + if (!initialScrollPendingRef.current && !sidebarBecameVisible && !routeThreadChanged) { + sidebarWasVisibleRef.current = true; + if (sidebarNavigationThreadKeyRef.current === routeThreadKey) { + sidebarNavigationThreadKeyRef.current = null; + } + return; + } + + const activeThread = document.querySelector( + `[data-sidebar-thread-key="${globalThis.CSS.escape(routeThreadKey)}"]`, + ); + if (!activeThread) return; + + activeThread.scrollIntoView({ + behavior: + initialScrollPendingRef.current || sidebarBecameVisible || prefersReducedMotion + ? "instant" + : "smooth", + block: "center", + }); + sidebarWasVisibleRef.current = true; + lastRouteThreadKeyRef.current = routeThreadKey; + initialScrollPendingRef.current = false; + sidebarNavigationThreadKeyRef.current = null; + }, [hasThreadRoute, prefersReducedMotion, routeThreadKey, sidebarIsVisible]); + + return markSidebarThreadNavigation; +} diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 0697fa4fe..e464ad95c 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -297,7 +297,13 @@ function waitForBootstrapRetry(delayMs: number): Promise { function isTransientBootstrapError(error: unknown): boolean { if (isPrimaryEnvironmentRequestError(error)) { - return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); + return ( + TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || isTransientBootstrapError(error.cause) + ); + } + + if (HttpClientError.isHttpClientError(error)) { + return error.reason._tag === "TransportError"; } if (error instanceof TypeError) { diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 87c352244..310fe7f3a 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -1,4 +1,5 @@ import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; +import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; @@ -8,19 +9,29 @@ export interface HostedPairingRequest { readonly label: string; } -export type HostedAppChannel = "latest" | "nightly"; +export type HostedAppChannel = "latest" | "nightly" | "canary"; export function configuredHostedAppUrl(): string { return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; } +export function isDesktopBackendless(): boolean { + if (typeof window === "undefined" || window.desktopBridge === undefined) { + return false; + } + return !window.desktopBridge + .getLocalEnvironmentBootstraps() + .some((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID); +} + function configuredBackendUrl(): string { return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || ""; } function configuredHostedAppChannel(): HostedAppChannel | null { const channel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); - return channel === "latest" || channel === "nightly" ? channel : null; + if (channel === "latest" || channel === "nightly" || channel === "canary") return channel; + return null; } function originFromUrl(value: string): string | null { @@ -32,6 +43,9 @@ function originFromUrl(value: string): string | null { } export function isHostedStaticApp(url?: URL): boolean { + if (isDesktopBackendless()) { + return true; + } if (configuredBackendUrl()) { return false; } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 33c53a86c..a821538b4 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -124,7 +124,8 @@ describe("LocalApi", () => { it("delegates host capabilities and persistence to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); - const getClientSettings = vi.fn().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); + const clientSettings = { ...DEFAULT_CLIENT_SETTINGS, contextMenuStyle: "native" as const }; + const getClientSettings = vi.fn().mockResolvedValue(clientSettings); const setClientSettings = vi.fn().mockResolvedValue(undefined); testWindow().desktopBridge = { showContextMenu, @@ -141,12 +142,12 @@ describe("LocalApi", () => { requestConfirmDialogMock.mockReturnValue(undefined); await expect(api.dialogs.confirm("Install update?")).resolves.toBe(false); await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); - await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS); + await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS); expect(showContextMenu).toHaveBeenCalledWith(items, undefined); expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp" }); - expect(getClientSettings).toHaveBeenCalledTimes(1); + expect(getClientSettings).toHaveBeenCalledTimes(2); expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index cafd04f9c..f2548d6a5 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,11 +1,36 @@ -import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts"; +import { + DEFAULT_CLIENT_SETTINGS, + type ConfirmDialogOptions, + type ContextMenuItem, + type ContextMenuStyle, + type LocalApi, +} from "@t3tools/contracts"; import { requestConfirmDialog } from "./confirmDialog"; import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; +import { isMacPlatform } from "./lib/utils"; let cachedApi: LocalApi | undefined; +async function readContextMenuStyle(): Promise { + try { + const settings = window.desktopBridge + ? await window.desktopBridge.getClientSettings() + : readBrowserClientSettings(); + return settings?.contextMenuStyle ?? DEFAULT_CLIENT_SETTINGS.contextMenuStyle; + } catch { + return DEFAULT_CLIENT_SETTINGS.contextMenuStyle; + } +} + +function shouldUseNativeContextMenu(style: ContextMenuStyle): boolean { + if (style === "custom") return false; + if (style === "native") return true; + const platform = typeof navigator === "undefined" ? "" : navigator.platform; + return Boolean(window.desktopBridge) && isMacPlatform(platform); +} + function createBrowserLocalApi(): LocalApi { return { dialogs: { @@ -46,8 +71,13 @@ function createBrowserLocalApi(): LocalApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ): Promise => { - if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; + const style = await readContextMenuStyle(); + if (shouldUseNativeContextMenu(style) && window.desktopBridge) { + try { + return (await window.desktopBridge.showContextMenu(items, position)) as T | null; + } catch { + return null; + } } return showContextMenuFallback(items, position); }, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 1a95acbcf..d66eba755 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -26,6 +26,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + sidebarEnvironmentHiddenById: {}, ...overrides, }; } @@ -184,6 +185,7 @@ describe("parsePersistedState", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + sidebarEnvironmentHiddenById: {}, threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index b14ce917c..878d6491c 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -29,6 +29,7 @@ export interface PersistedUiState { sidebarProjectScopeKey?: string | null; threadChangedFilesExpansionVersion?: number; threadChangedFilesExpandedById?: Record>; + sidebarEnvironmentHiddenById?: Record; } export interface UiProjectState { @@ -49,7 +50,12 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiEnvironmentState { + sidebarEnvironmentHiddenById: Record; +} + +export interface UiState + extends UiProjectState, UiThreadState, UiEndpointState, UiEnvironmentState {} const initialState: UiState = { projectExpandedById: {}, @@ -58,6 +64,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + sidebarEnvironmentHiddenById: {}, }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -143,6 +150,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { : {}, defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey), + sidebarEnvironmentHiddenById: sanitizeBooleanRecord(parsed.sidebarEnvironmentHiddenById), }; } @@ -214,6 +222,9 @@ export function persistState(state: UiState): void { threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, sidebarProjectScopeKey: state.sidebarProjectScopeKey, + ...(Object.keys(state.sidebarEnvironmentHiddenById).length > 0 + ? { sidebarEnvironmentHiddenById: state.sidebarEnvironmentHiddenById } + : {}), threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, } satisfies PersistedUiState), @@ -407,6 +418,7 @@ interface UiStateStore extends UiState { setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setSidebarProjectScopeKey: (projectKey: string | null) => void; + setSidebarEnvironmentVisible: (environmentId: string, visible: boolean) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -427,6 +439,13 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setSidebarProjectScopeKey: (projectKey) => set((state) => setSidebarProjectScopeKey(state, projectKey)), + setSidebarEnvironmentVisible: (environmentId, visible) => + set((state) => { + const next = { ...state.sidebarEnvironmentHiddenById }; + if (visible) delete next[environmentId]; + else next[environmentId] = true; + return { ...state, sidebarEnvironmentHiddenById: next }; + }), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 0c889f17c..a14a3cda5 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -63,14 +63,15 @@ export function resolveVersionMismatch( const clientCore = versionCore(normalizedClientVersion); const serverCore = versionCore(normalizedServerVersion); - const compareNightlyBuilds = - parseSemver(normalizedClientVersion)?.prerelease[0] === "nightly" && - parseSemver(normalizedServerVersion)?.prerelease[0] === "nightly"; + const clientChannel = parseSemver(normalizedClientVersion)?.prerelease[0]; + const serverChannel = parseSemver(normalizedServerVersion)?.prerelease[0]; + const comparePrereleaseBuilds = + (clientChannel === "nightly" || clientChannel === "canary") && clientChannel === serverChannel; const serverIsBehind = parseSemver(clientCore) && parseSemver(serverCore) ? compareSemverVersions( - compareNightlyBuilds ? normalizedServerVersion : serverCore, - compareNightlyBuilds ? normalizedClientVersion : clientCore, + comparePrereleaseBuilds ? normalizedServerVersion : serverCore, + comparePrereleaseBuilds ? normalizedClientVersion : clientCore, ) < 0 : normalizedServerVersion !== normalizedClientVersion; if (!serverIsBehind) { diff --git a/docs/operations/release.md b/docs/operations/release.md index 92f7278d7..464147c22 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -227,6 +227,9 @@ available. - No automatic download or install. - The desktop UI shows a rocket update button when an update is available; click once to download, click again after download to restart/install. - Provider: GitHub Releases (`provider: github`) configured at build time. +- Installation: + - Linux AppImage and Windows NSIS builds use `electron-updater`'s standard installer. + - macOS uses `MacUpdater` for checks, architecture selection, download, and SHA-512 verification. A detached helper extracts the ZIP, clears quarantine, replaces the installed app bundle, and restores the previous bundle if replacement or relaunch fails. Protected install locations request administrator privileges. - Repository slug source: - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set. - otherwise `GITHUB_REPOSITORY` from GitHub Actions. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a14a4ed0a..c0674729c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -168,8 +168,8 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; -export type DesktopUpdateChannel = "latest" | "nightly"; -export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export type DesktopUpdateChannel = "latest" | "nightly" | "canary"; +export type DesktopAppStageLabel = "Alpha" | "Canary" | "Dev" | "Nightly"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", @@ -183,8 +183,8 @@ export const DesktopUpdateStatusSchema = Schema.Literals([ ]); export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); -export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); -export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly", "canary"]); +export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Canary", "Dev", "Nightly"]); export interface DesktopAppBranding { baseName: string; @@ -1074,6 +1074,7 @@ export interface DesktopBridge { // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; + setLocalBackendEnabled: (enabled: boolean) => Promise; getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 43e7645b3..ff04e85f7 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; const THREAD_KEYBINDING_COMMANDS = [ + "thread.rename", "thread.previous", "thread.next", "thread.copyReference", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 356532026..3a982b7cf 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -32,6 +32,14 @@ const ProviderSessionStatus = Schema.Literals([ "closed", ]); +const ProviderEnvKey = Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/)).check( + Schema.isMaxLength(128), +); +const ProviderEnvValue = Schema.String.check(Schema.isMaxLength(8_192)); +export const ProviderEnv = Schema.Record(ProviderEnvKey, ProviderEnvValue).check( + Schema.isMaxProperties(256), +); + export const ProviderSession = Schema.Struct({ provider: ProviderDriverKind, // Optional during the driver/instance migration. Once every producer @@ -60,6 +68,7 @@ export const ProviderSessionStartInput = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), + env: Schema.optional(ProviderEnv), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), runtimeMode: RuntimeMode, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7e25c8444..bd767d8e2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,6 +38,10 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const ContextMenuStyle = Schema.Literals(["default", "native", "custom"]); +export type ContextMenuStyle = typeof ContextMenuStyle.Type; +export const DEFAULT_CONTEXT_MENU_STYLE: ContextMenuStyle = "default"; + export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; @@ -267,6 +271,9 @@ export const ClientSettingsSchema = Schema.Struct({ ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + contextMenuStyle: ContextMenuStyle.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_CONTEXT_MENU_STYLE)), + ), confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), @@ -1231,6 +1238,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + contextMenuStyle: Schema.optionalKey(ContextMenuStyle), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffLayout: Schema.optionalKey(DiffLayout), diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 36e3d339f..99a795cb0 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -37,8 +37,13 @@ const TerminalSessionInput = Schema.Struct({ }); export type TerminalSessionInput = Schema.Codec.Encoded; +const TerminalDraftProjectInput = Schema.Struct({ + projectId: Schema.optional(TrimmedNonEmptyString), +}); + export const TerminalOpenInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalDraftProjectInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: Schema.optional(TerminalColsSchema), @@ -50,6 +55,7 @@ export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalDraftProjectInput.fields, cwd: Schema.optional(TrimmedNonEmptyStringSchema), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: Schema.optional(TerminalColsSchema), @@ -78,6 +84,7 @@ export type TerminalClearInput = Schema.Codec.Encoded export const TerminalRestartInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalDraftProjectInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: TerminalColsSchema, diff --git a/packages/shared/package.json b/packages/shared/package.json index a9f60297f..7f74f5f7c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,6 +103,10 @@ "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" }, + "./projectLaunchEnv": { + "types": "./src/projectLaunchEnv.ts", + "import": "./src/projectLaunchEnv.ts" + }, "./threadEnvMode": { "types": "./src/threadEnvMode.ts", "import": "./src/threadEnvMode.ts" diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 1107873c8..ef9e7a146 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -45,6 +45,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "f2", command: "thread.rename" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, diff --git a/packages/shared/src/projectLaunchEnv.ts b/packages/shared/src/projectLaunchEnv.ts new file mode 100644 index 000000000..fedd3b684 --- /dev/null +++ b/packages/shared/src/projectLaunchEnv.ts @@ -0,0 +1,52 @@ +export type EnvRecord = Readonly>; + +const MANAGED_RUNTIME_ENV_KEYS = new Set([ + "T3CODE_HOME", + "T3CODE_PROJECT_ROOT", + "T3CODE_PROJECT_ID", + "T3CODE_THREAD_ID", + "T3CODE_WORKTREE_PATH", +]); + +export function isManagedRuntimeEnvKey(key: string): boolean { + return MANAGED_RUNTIME_ENV_KEYS.has(key.toUpperCase()); +} + +export function stripManagedRuntimeEnvKeys(env: EnvRecord | undefined): Record { + const next: Record = {}; + if (!env) return next; + for (const [key, value] of Object.entries(env)) { + if (value === undefined || isManagedRuntimeEnvKey(key)) continue; + next[key] = value; + } + return next; +} + +export interface ProjectLaunchEnvContextInput { + readonly projectRoot: string; + readonly projectId: string; + readonly threadId: string; + readonly worktreePath?: string | null | undefined; +} + +export function buildLaunchContextEnv(input: ProjectLaunchEnvContextInput): Record { + const env: Record = { + T3CODE_PROJECT_ROOT: input.projectRoot, + T3CODE_PROJECT_ID: input.projectId, + T3CODE_THREAD_ID: input.threadId, + }; + if (input.worktreePath) env.T3CODE_WORKTREE_PATH = input.worktreePath; + return env; +} + +export function mergeResolvedProjectLaunchEnv(input: { + readonly t3Home: string; + readonly extraEnv?: EnvRecord; + readonly context: ProjectLaunchEnvContextInput; +}): Record { + return { + ...stripManagedRuntimeEnvKeys(input.extraEnv), + T3CODE_HOME: input.t3Home, + ...buildLaunchContextEnv(input.context), + }; +} diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 7a9467037..5b924baf0 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -370,6 +370,9 @@ export function resolveRemoteT3CliPackageSpec(input: { readonly isDevelopment?: boolean; }): string { const appVersion = input.appVersion.trim(); + if (!input.isDevelopment && appVersion.includes("-canary.")) { + return "t3@nightly"; + } if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { return `t3@${appVersion}`; } @@ -378,5 +381,5 @@ export function resolveRemoteT3CliPackageSpec(input: { return "t3@nightly"; } - return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; + return input.updateChannel === "latest" ? "t3@latest" : `t3@${input.updateChannel}`; } From 788a32d286c2ce7c1bc4e4f2b6190b60318e6975 Mon Sep 17 00:00:00 2001 From: "t3code-release[bot]" Date: Mon, 7 Sep 2026 12:32:32 +0000 Subject: [PATCH 7/7] prepare stable release 2026.9.700 Release-PR: #165 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- nix/package.nix | 2 +- packages/contracts/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c74c429b0..76fa61d88 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.39", + "version": "2026.9.700", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 300117b24..d1c346844 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.39", + "version": "2026.9.700", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 7fbcd6def..9c6475707 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.39", + "version": "2026.9.700", "private": true, "type": "module", "scripts": { diff --git a/nix/package.nix b/nix/package.nix index a0ccc42fa..ceb0e8c40 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -50,7 +50,7 @@ stdenv.mkDerivation (finalAttrs: { ; inherit pnpm; fetcherVersion = 4; - hash = "sha256-mgRMeBpJmiTat38APyE4guNJ+6RiQhenphP7tRcmc+k="; + hash = "sha256-hYyiJ6FyNuG4594xObhMFIFBp5FZqK7o6sNnmryR/Jc="; }; postPatch = lib.optionalString (finalAttrs.version != sourceVersion) '' diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 411a48da6..53f8294eb 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.39", + "version": "2026.9.700", "private": true, "files": [ "dist"